@pressgang-wp/shakedown 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/bin/derive-matrix.mjs +18 -0
- package/bin/matrix.php +166 -0
- package/bin/seed-states.php +116 -0
- package/bin/shakedown.mjs +166 -0
- package/lib/derive.mjs +152 -0
- package/lib/sandbox.mjs +440 -0
- package/lib/target.mjs +99 -0
- package/lib/trial-reporter.mjs +163 -0
- package/package.json +37 -0
- package/php/observer.php +69 -0
- package/playwright.config.mjs +45 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trial Report reporter: turns a run into a client-readable, self-contained
|
|
3
|
+
* HTML page (route × pass matrix, summary, plain-English failures) plus a
|
|
4
|
+
* machine-readable run.json — the QA handover artifact, distinct from
|
|
5
|
+
* Playwright's developer report (traces and stack dumps stay dev-side).
|
|
6
|
+
*
|
|
7
|
+
* Written to <workspace>/.shakedown/{run.json, trial-report.html}.
|
|
8
|
+
*/
|
|
9
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const PASS_NAMES = { '00': 'Availability', '01': 'Integrity', '02': 'Accessibility', '03': 'Visual' };
|
|
13
|
+
|
|
14
|
+
export default class TrialReporter {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.workspace = process.env.SHAKEDOWN_WORKSPACE ?? process.cwd();
|
|
17
|
+
this.results = new Map(); // test.id → latest attempt
|
|
18
|
+
this.started = new Date();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
onTestEnd(test, result) {
|
|
22
|
+
const match = test.title.match(/^(\d\d) (\S+) (\S+)$/);
|
|
23
|
+
if (!match) return; // journeys and other suites: v1 reports derived passes only
|
|
24
|
+
|
|
25
|
+
this.results.set(test.id, {
|
|
26
|
+
pass: match[1],
|
|
27
|
+
kind: match[2],
|
|
28
|
+
url: match[3],
|
|
29
|
+
status: result.status,
|
|
30
|
+
error: result.error ? String(result.error.message ?? '').split('\n')[0].slice(0, 300) : null,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
onEnd() {
|
|
35
|
+
const entries = [...this.results.values()];
|
|
36
|
+
if (entries.length === 0) return;
|
|
37
|
+
|
|
38
|
+
let target = {};
|
|
39
|
+
try {
|
|
40
|
+
const m = JSON.parse(readFileSync(join(this.workspace, '.shakedown', 'matrix.json'), 'utf8'));
|
|
41
|
+
target = { name: m.target, baseUrl: m.baseUrl };
|
|
42
|
+
} catch {
|
|
43
|
+
// matrix metadata is decoration; the report stands without it
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const run = { generated: this.started.toISOString(), target, results: entries };
|
|
47
|
+
const dir = join(this.workspace, '.shakedown');
|
|
48
|
+
mkdirSync(dir, { recursive: true });
|
|
49
|
+
|
|
50
|
+
const screenshots = this.collectScreenshots(entries, dir);
|
|
51
|
+
|
|
52
|
+
writeFileSync(join(dir, 'run.json'), JSON.stringify(run, null, 2));
|
|
53
|
+
writeFileSync(join(dir, 'trial-report.html'), render(run, screenshots));
|
|
54
|
+
console.log(`\n⚓ Trial report: ${join(dir, 'trial-report.html')}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Copy each route's pass-03 baseline into .shakedown/screenshots/ so the
|
|
59
|
+
* report and its images travel together (artifacts, zips, client emails).
|
|
60
|
+
*
|
|
61
|
+
* @returns {Map<string, string>} route url → report-relative image path.
|
|
62
|
+
*/
|
|
63
|
+
collectScreenshots(entries, reportDir) {
|
|
64
|
+
const root = join(this.workspace, 'tests', '__screenshots__');
|
|
65
|
+
const shots = new Map();
|
|
66
|
+
|
|
67
|
+
if (!existsSync(root)) return shots;
|
|
68
|
+
const platform = readdirSync(root).find((d) => !d.startsWith('.'));
|
|
69
|
+
if (!platform) return shots;
|
|
70
|
+
|
|
71
|
+
mkdirSync(join(reportDir, 'screenshots'), { recursive: true });
|
|
72
|
+
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (shots.has(entry.url)) continue;
|
|
75
|
+
// Mirrors snapshotName() in tests/03-visual.spec.mjs — keep in sync.
|
|
76
|
+
const path = new URL(entry.url).pathname.replace(/\W+/g, '-').replace(/^-|-$/g, '') || 'home';
|
|
77
|
+
const name = `${entry.kind.replace(/\W+/g, '-')}--${path}.png`;
|
|
78
|
+
const source = join(root, platform, name);
|
|
79
|
+
|
|
80
|
+
if (existsSync(source)) {
|
|
81
|
+
cpSync(source, join(reportDir, 'screenshots', name));
|
|
82
|
+
shots.set(entry.url, `screenshots/${name}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return shots;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {{generated: string, target: object, results: array}} run
|
|
92
|
+
* @param {Map<string, string>} screenshots route url → relative image path.
|
|
93
|
+
* @returns {string} HTML (images referenced from the sibling screenshots/ dir).
|
|
94
|
+
*/
|
|
95
|
+
function render(run, screenshots = new Map()) {
|
|
96
|
+
const passes = [...new Set(run.results.map((r) => r.pass))].sort();
|
|
97
|
+
const routes = new Map();
|
|
98
|
+
for (const r of run.results) {
|
|
99
|
+
if (!routes.has(r.url)) routes.set(r.url, { kind: r.kind, url: r.url, cells: {} });
|
|
100
|
+
routes.get(r.url).cells[r.pass] = r;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const total = run.results.length;
|
|
104
|
+
const failed = run.results.filter((r) => r.status === 'failed' || r.status === 'timedOut');
|
|
105
|
+
const skipped = run.results.filter((r) => r.status === 'skipped').length;
|
|
106
|
+
const passedCount = total - failed.length - skipped;
|
|
107
|
+
|
|
108
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
109
|
+
const mark = (r) =>
|
|
110
|
+
!r ? '<td class="na">–</td>'
|
|
111
|
+
: r.status === 'passed' || r.status === 'flaky' ? '<td class="ok">✓</td>'
|
|
112
|
+
: r.status === 'skipped' ? '<td class="na">–</td>'
|
|
113
|
+
: '<td class="bad">✗</td>';
|
|
114
|
+
|
|
115
|
+
const hasShots = screenshots.size > 0;
|
|
116
|
+
const rows = [...routes.values()]
|
|
117
|
+
.map((route) => {
|
|
118
|
+
const path = new URL(route.url).pathname + (new URL(route.url).search || '');
|
|
119
|
+
const shot = screenshots.get(route.url);
|
|
120
|
+
const preview = !hasShots ? ''
|
|
121
|
+
: shot ? `<td class="shot"><a href="${esc(shot)}"><img src="${esc(shot)}" alt="${esc(route.kind)}" loading="lazy"></a></td>`
|
|
122
|
+
: '<td class="shot"></td>';
|
|
123
|
+
return `<tr>${preview}<td class="kind">${esc(route.kind)}</td><td class="url">${esc(path)}</td>${passes.map((p) => mark(route.cells[p])).join('')}</tr>`;
|
|
124
|
+
})
|
|
125
|
+
.join('\n');
|
|
126
|
+
|
|
127
|
+
const failures = failed
|
|
128
|
+
.map((f) => `<li><strong>${esc(PASS_NAMES[f.pass] ?? f.pass)}</strong> — ${esc(f.kind)} <code>${esc(new URL(f.url).pathname)}</code><br>${esc(f.error ?? '')}</li>`)
|
|
129
|
+
.join('\n');
|
|
130
|
+
|
|
131
|
+
return `<!doctype html>
|
|
132
|
+
<meta charset="utf-8">
|
|
133
|
+
<title>Trial Report — ${esc(run.target.name ?? 'shakedown')}</title>
|
|
134
|
+
<style>
|
|
135
|
+
body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 60rem; padding: 0 1rem; color: #1b2a32; }
|
|
136
|
+
h1 { font-size: 1.4rem; } .meta { color: #5b6b72; font-size: .9rem; }
|
|
137
|
+
.strip { display: flex; gap: 2rem; margin: 1.2rem 0; }
|
|
138
|
+
.stat b { display: block; font-size: 1.6rem; font-variant-numeric: tabular-nums; }
|
|
139
|
+
.stat span { color: #5b6b72; font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; }
|
|
140
|
+
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
|
|
141
|
+
th, td { padding: .35rem .6rem; border-bottom: 1px solid #dde2dc; text-align: left; }
|
|
142
|
+
th { font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; color: #5b6b72; }
|
|
143
|
+
td.ok { color: #2e7d4f; } td.bad { color: #b3453a; font-weight: 700; } td.na { color: #9aa7a0; }
|
|
144
|
+
td.kind { font-family: ui-monospace, monospace; font-size: .75rem; white-space: nowrap; }
|
|
145
|
+
td.shot img { width: 96px; max-height: 64px; object-fit: cover; object-position: top; border: 1px solid #dde2dc; display: block; }
|
|
146
|
+
td.url { font-family: ui-monospace, monospace; font-size: .75rem; word-break: break-all; }
|
|
147
|
+
ul.failures li { margin-bottom: .7rem; } code { background: #eef1eb; padding: 0 .3em; }
|
|
148
|
+
</style>
|
|
149
|
+
<h1>⚓ Trial Report — ${esc(run.target.name ?? 'shakedown')}</h1>
|
|
150
|
+
<p class="meta">${esc(run.target.baseUrl ?? '')} · generated ${esc(run.generated)}</p>
|
|
151
|
+
<div class="strip">
|
|
152
|
+
<div class="stat"><b>${routes.size}</b><span>routes</span></div>
|
|
153
|
+
<div class="stat"><b>${passedCount}</b><span>checks passed</span></div>
|
|
154
|
+
<div class="stat"><b>${failed.length}</b><span>failed</span></div>
|
|
155
|
+
<div class="stat"><b>${skipped}</b><span>skipped</span></div>
|
|
156
|
+
</div>
|
|
157
|
+
<table>
|
|
158
|
+
<tr>${hasShots ? '<th>Preview</th>' : ''}<th>Kind</th><th>Route</th>${passes.map((p) => `<th>${esc(PASS_NAMES[p] ?? p)}</th>`).join('')}</tr>
|
|
159
|
+
${rows}
|
|
160
|
+
</table>
|
|
161
|
+
${failed.length ? `<h2>Failures</h2><ul class="failures">${failures}</ul>` : '<p><strong>All checks passed.</strong></p>'}
|
|
162
|
+
`;
|
|
163
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pressgang-wp/shakedown",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sea trials for PressGang themes — config-derived e2e smoke testing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/pressgang-wp/pressgang-shakedown.git"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"shakedown": "bin/shakedown.mjs"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"lib",
|
|
20
|
+
"php",
|
|
21
|
+
"playwright.config.mjs",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"matrix": "node bin/derive-matrix.mjs",
|
|
29
|
+
"test:unit": "node --test tests/unit/*.test.mjs",
|
|
30
|
+
"test": "playwright test",
|
|
31
|
+
"test:ui": "playwright test --ui"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@axe-core/playwright": "^4.12.1",
|
|
35
|
+
"@playwright/test": "^1.58.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/php/observer.php
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* Shakedown observer (sandbox mu-plugin; never installed on real sites in v1).
|
|
4
|
+
*
|
|
5
|
+
* Makes a request's inner workings observable via response headers so passes
|
|
6
|
+
* can assert against the Capstan oracle and catch silent PHP issues:
|
|
7
|
+
*
|
|
8
|
+
* X-Shakedown-Template basename of the PHP template WordPress chose
|
|
9
|
+
* X-Shakedown-Controller snake_case short name of the PressGang controller
|
|
10
|
+
* that rendered (from the pressgang_render_{key} action)
|
|
11
|
+
* X-Shakedown-Php-Issues count of notices/warnings/deprecations raised
|
|
12
|
+
* X-Shakedown-Php-Sample first few issues, rawurlencoded, for failure output
|
|
13
|
+
*
|
|
14
|
+
* Output is buffered for the whole request so the headers can still be sent
|
|
15
|
+
* from the shutdown handler, after all issues have been counted.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
if ( ! defined( 'ABSPATH' ) ) {
|
|
19
|
+
exit;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
$GLOBALS['shakedown_observer'] = [ 'issues' => [], 'template' => '', 'controller' => '' ];
|
|
23
|
+
|
|
24
|
+
set_error_handler( static function ( int $errno, string $errstr, string $errfile = '', int $errline = 0 ): bool {
|
|
25
|
+
$tracked = E_NOTICE | E_WARNING | E_DEPRECATED | E_USER_NOTICE | E_USER_WARNING | E_USER_DEPRECATED;
|
|
26
|
+
|
|
27
|
+
if ( ( $errno & $tracked ) !== 0 ) {
|
|
28
|
+
$GLOBALS['shakedown_observer']['issues'][] = sprintf( '%s in %s:%d', $errstr, basename( $errfile ), $errline );
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return false; // Never swallow: default logging/display still applies.
|
|
32
|
+
} );
|
|
33
|
+
|
|
34
|
+
ob_start();
|
|
35
|
+
|
|
36
|
+
add_filter( 'template_include', static function ( $template ) {
|
|
37
|
+
$GLOBALS['shakedown_observer']['template'] = basename( (string) $template );
|
|
38
|
+
|
|
39
|
+
return $template;
|
|
40
|
+
}, PHP_INT_MAX );
|
|
41
|
+
|
|
42
|
+
// AbstractController::render() fires pressgang_render_{snake_case_controller};
|
|
43
|
+
// the 'all' hook lets the observer learn which controller ran without
|
|
44
|
+
// requiring a framework change.
|
|
45
|
+
add_action( 'all', static function (): void {
|
|
46
|
+
$hook = current_filter();
|
|
47
|
+
|
|
48
|
+
if ( str_starts_with( $hook, 'pressgang_render_' ) && $hook !== 'pressgang_render_failed' ) {
|
|
49
|
+
$GLOBALS['shakedown_observer']['controller'] = substr( $hook, strlen( 'pressgang_render_' ) );
|
|
50
|
+
}
|
|
51
|
+
} );
|
|
52
|
+
|
|
53
|
+
register_shutdown_function( static function (): void {
|
|
54
|
+
$observer = $GLOBALS['shakedown_observer'];
|
|
55
|
+
|
|
56
|
+
if ( ! headers_sent() ) {
|
|
57
|
+
header( 'X-Shakedown-Template: ' . $observer['template'] );
|
|
58
|
+
header( 'X-Shakedown-Controller: ' . $observer['controller'] );
|
|
59
|
+
header( 'X-Shakedown-Php-Issues: ' . count( $observer['issues'] ) );
|
|
60
|
+
|
|
61
|
+
if ( $observer['issues'] !== [] ) {
|
|
62
|
+
header( 'X-Shakedown-Php-Sample: ' . substr( rawurlencode( implode( ' | ', array_slice( $observer['issues'], 0, 3 ) ) ), 0, 900 ) );
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
while ( ob_get_level() > 0 ) {
|
|
67
|
+
ob_end_flush();
|
|
68
|
+
}
|
|
69
|
+
} );
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { defineConfig } from '@playwright/test';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared runner config for all shakedown passes.
|
|
8
|
+
*
|
|
9
|
+
* The workspace (SHAKEDOWN_WORKSPACE, set by the CLI — falls back to this
|
|
10
|
+
* package for standalone use) owns all run artifacts: matrix, reports,
|
|
11
|
+
* traces. The derived passes live in this package; a workspace `tests/e2e/`
|
|
12
|
+
* directory, when present, runs alongside them as the journeys suite.
|
|
13
|
+
*
|
|
14
|
+
* `ignoreHTTPSErrors` accommodates self-signed local TLS (.test domains);
|
|
15
|
+
* traces are kept on failure so any red route can be replayed step-by-step.
|
|
16
|
+
*/
|
|
17
|
+
const pkgRoot = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const workspace = process.env.SHAKEDOWN_WORKSPACE ?? pkgRoot;
|
|
19
|
+
const journeysDir = join(workspace, 'tests/e2e');
|
|
20
|
+
|
|
21
|
+
export default defineConfig({
|
|
22
|
+
fullyParallel: true,
|
|
23
|
+
// Attached mode drives one shared PHP-FPM; a single retry absorbs load transients.
|
|
24
|
+
retries: 1,
|
|
25
|
+
outputDir: join(workspace, 'test-results'),
|
|
26
|
+
// Visual baselines belong to the THEME (committed in its repo), keyed by
|
|
27
|
+
// platform because font rendering differs across OSes — macOS baselines
|
|
28
|
+
// and CI Linux baselines coexist.
|
|
29
|
+
snapshotPathTemplate: join(workspace, 'tests', '__screenshots__', '{platform}', '{arg}{ext}'),
|
|
30
|
+
reporter: [
|
|
31
|
+
['list'],
|
|
32
|
+
['html', { outputFolder: join(workspace, 'playwright-report'), open: 'never' }],
|
|
33
|
+
[join(pkgRoot, 'lib', 'trial-reporter.mjs')],
|
|
34
|
+
],
|
|
35
|
+
use: {
|
|
36
|
+
ignoreHTTPSErrors: true,
|
|
37
|
+
trace: 'retain-on-failure',
|
|
38
|
+
},
|
|
39
|
+
projects: [
|
|
40
|
+
{ name: 'derived', testDir: join(pkgRoot, 'tests') },
|
|
41
|
+
...(existsSync(journeysDir) && journeysDir !== join(pkgRoot, 'tests')
|
|
42
|
+
? [{ name: 'journeys', testDir: journeysDir }]
|
|
43
|
+
: []),
|
|
44
|
+
],
|
|
45
|
+
});
|