@pressgang-wp/shakedown 0.1.0 → 0.1.1
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/README.md +4 -3
- package/bin/shakedown.mjs +12 -5
- package/lib/sandbox.mjs +41 -0
- package/package.json +2 -1
- package/tests/00-availability.spec.mjs +66 -0
- package/tests/01-integrity.spec.mjs +46 -0
- package/tests/02-accessibility.spec.mjs +53 -0
- package/tests/03-visual.spec.mjs +50 -0
- package/tests/matrix.mjs +70 -0
- package/tests/unit/sandbox-seeding.test.mjs +68 -0
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Point it at a running site and in under a minute you'll know: does every page re
|
|
|
13
13
|
You need Node 20+, WP-CLI, and a PressGang site running locally (any server — Herd, Valet, DDEV, MAMP… it's just a URL). From inside your theme:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
npm i -D
|
|
16
|
+
npm i -D @pressgang-wp/shakedown
|
|
17
17
|
npx playwright install chromium # once per machine
|
|
18
18
|
npx shakedown # ⚓ derive the matrix, run every pass
|
|
19
19
|
```
|
|
@@ -45,6 +45,8 @@ Optional `shakedown.config.json` in the theme, for overrides only:
|
|
|
45
45
|
|
|
46
46
|
Authored journeys (form submissions, checkout flows) live in your theme's `tests/e2e/` — when present they run alongside the derived passes as the `journeys` project.
|
|
47
47
|
|
|
48
|
+
**Seeding is convention-first.** If your theme ships [Muster](https://github.com/pressgang-wp/pressgang-muster) seeders (`src/Muster/*`), the sandbox runs them via `wp capstan seed` as the baseline — your real menus, terms and pages — then layers the derived populated/minimal ACF state fixtures on top. A theme that ships no seeders is unaffected: the derived layer covers it on its own, so it does a good job out of the box.
|
|
49
|
+
|
|
48
50
|
Sandbox fixture randomness and time are separate deterministic inputs. `seed`
|
|
49
51
|
controls generated values; `epoch` fixes relative dates used by Muster,
|
|
50
52
|
including ACF date fields and the fixture posts themselves. It must be a
|
|
@@ -132,7 +134,7 @@ Shakedown is part of the [PressGang](https://pressgang.dev) ecosystem and is des
|
|
|
132
134
|
| --- | --- |
|
|
133
135
|
| [pressgang](https://github.com/pressgang-wp/pressgang) | The parent theme framework (Timber + Twig, config-driven) |
|
|
134
136
|
| [capstan](https://github.com/pressgang-wp/pressgang-capstan) | WP-CLI scaffolding & introspection — future source of the route matrix and per-URL controller/template oracle |
|
|
135
|
-
| [muster](https://github.com/pressgang-wp/pressgang-muster) |
|
|
137
|
+
| [muster](https://github.com/pressgang-wp/pressgang-muster) | Runs the theme's own seeders as the sandbox baseline, and seeds deterministic populated/minimal ACF states on top |
|
|
136
138
|
| [bosun](https://github.com/pressgang-wp/pressgang-bosun) | AI-agent guidelines & skills — future distribution channel for Shakedown's QA skills |
|
|
137
139
|
|
|
138
140
|
## 🛠️ Roadmap
|
|
@@ -142,7 +144,6 @@ Shakedown is part of the [PressGang](https://pressgang.dev) ecosystem and is des
|
|
|
142
144
|
- More passes: **accessibility** (axe-core), **visual snapshots**
|
|
143
145
|
- **Trial Report** — a client-readable HTML report with screenshots and coverage
|
|
144
146
|
- Engines: self-booting **WordPress Playground**, per-PR **InstaWP** CI sites, **wp-env** fidelity lane
|
|
145
|
-
- Published as `@pressgang-wp/shakedown` on npm
|
|
146
147
|
|
|
147
148
|
## 📋 Requirements
|
|
148
149
|
|
package/bin/shakedown.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
19
19
|
import { existsSync } from 'node:fs';
|
|
20
20
|
import { resolveTarget } from '../lib/target.mjs';
|
|
21
21
|
import { capstanDoctor, deriveMatrix, mergeRoutes } from '../lib/derive.mjs';
|
|
22
|
-
import { bootSandbox, DEFAULT_FIXTURE_EPOCH, seedAcfStates } from '../lib/sandbox.mjs';
|
|
22
|
+
import { bootSandbox, DEFAULT_FIXTURE_EPOCH, seedAcfStates, seedThemeMuster } from '../lib/sandbox.mjs';
|
|
23
23
|
|
|
24
24
|
const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
25
25
|
const workspace = process.cwd();
|
|
@@ -130,13 +130,20 @@ try {
|
|
|
130
130
|
// ACF state fixtures — populated + minimal per field group. Seeding
|
|
131
131
|
// is sandbox-only by design: the isolation witness has already
|
|
132
132
|
// proven this database is throwaway.
|
|
133
|
+
const seed = target.sandbox?.seed ?? 42;
|
|
134
|
+
const epoch = target.sandbox?.epoch ?? DEFAULT_FIXTURE_EPOCH;
|
|
135
|
+
|
|
136
|
+
// Convention-first baseline: run the theme's own Muster seeders so its
|
|
137
|
+
// real menus, terms and pages are present. Falls through silently when
|
|
138
|
+
// the theme ships none — the derived ACF states below still cover it.
|
|
139
|
+
if (seedThemeMuster(sandbox, { seed })) {
|
|
140
|
+
console.log('⚓ seeded theme baseline via `wp capstan seed`');
|
|
141
|
+
}
|
|
142
|
+
|
|
133
143
|
let states = [];
|
|
134
144
|
const muster = findMusterAutoload(target);
|
|
135
145
|
if (muster) {
|
|
136
|
-
states = seedAcfStates(sandbox, muster, {
|
|
137
|
-
seed: target.sandbox?.seed ?? 42,
|
|
138
|
-
epoch: target.sandbox?.epoch ?? DEFAULT_FIXTURE_EPOCH,
|
|
139
|
-
});
|
|
146
|
+
states = seedAcfStates(sandbox, muster, { seed, epoch });
|
|
140
147
|
console.log(`⚓ seeded ${states.length} ACF state fixtures via Muster`);
|
|
141
148
|
} else {
|
|
142
149
|
console.log('⚓ Muster not found — skipping ACF state fixtures (set sandbox.musterPath)');
|
package/lib/sandbox.mjs
CHANGED
|
@@ -306,6 +306,47 @@ export async function bootSandbox(target, options = {}) {
|
|
|
306
306
|
};
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Convention-first baseline seeding: run the theme's OWN Muster seeders
|
|
311
|
+
* (anything under its `src/Muster/`) via `wp capstan seed`, so a theme that
|
|
312
|
+
* ships fixtures gets its real content — menus assigned to locations, terms,
|
|
313
|
+
* pages, relationships — exactly as `wp capstan seed` produces on a dev site.
|
|
314
|
+
* The ACF state fixtures (see {@link seedAcfStates}) then layer on top.
|
|
315
|
+
*
|
|
316
|
+
* This is a deliberate no-op — returning false so the caller falls back to
|
|
317
|
+
* deriving everything from acf-json — when the theme ships no Muster classes
|
|
318
|
+
* or Capstan isn't installed to discover them. Out of the box, a theme with no
|
|
319
|
+
* fixtures still gets a fully derived suite; a theme with fixtures gets its own.
|
|
320
|
+
*
|
|
321
|
+
* @param {{root: string, theme: string|null}} sandbox
|
|
322
|
+
* @param {{seed?: number}} options Deterministic random seed passed through to Muster.
|
|
323
|
+
* @returns {boolean} True when theme seeders ran; false when none were applied.
|
|
324
|
+
*/
|
|
325
|
+
export function seedThemeMuster(sandbox, { seed = 42 } = {}) {
|
|
326
|
+
const musterDir = join(sandbox.root, 'wp-content', 'themes', sandbox.theme ?? '', 'src', 'Muster');
|
|
327
|
+
|
|
328
|
+
if (!sandbox.theme || !existsSync(musterDir)) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
// `wp capstan seed` is Capstan's discovery-and-run of the theme's Muster
|
|
334
|
+
// subclasses; probe for it so a missing Capstan degrades to the fallback
|
|
335
|
+
// rather than throwing.
|
|
336
|
+
execFileSync('wp', ['cli', 'has-command', 'capstan seed', `--path=${sandbox.root}`], { stdio: 'ignore' });
|
|
337
|
+
} catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
execFileSync(
|
|
342
|
+
'wp',
|
|
343
|
+
['capstan', 'seed', `--seed=${seed}`, `--path=${sandbox.root}`],
|
|
344
|
+
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
309
350
|
/**
|
|
310
351
|
* Seed ACF state fixtures (populated + minimal per field group) inside a
|
|
311
352
|
* booted sandbox, via Muster. Only ever called for sandboxes — the isolation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pressgang-wp/shakedown",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Sea trials for PressGang themes — config-derived e2e smoke testing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"bin",
|
|
19
19
|
"lib",
|
|
20
20
|
"php",
|
|
21
|
+
"tests",
|
|
21
22
|
"playwright.config.mjs",
|
|
22
23
|
"README.md"
|
|
23
24
|
],
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { loadMatrix, controllerHeaderName, ERROR_SIGNATURES } from './matrix.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pass 00 — availability.
|
|
6
|
+
*
|
|
7
|
+
* Every derived route must return its intended status with a body free of
|
|
8
|
+
* PHP/Twig error signatures and carrying a <title>. HTTP-only (no browser),
|
|
9
|
+
* so this pass sweeps the whole site in seconds. Where the observer answers
|
|
10
|
+
* (sandbox), each route is also checked against the Capstan oracle and for
|
|
11
|
+
* silent PHP notices.
|
|
12
|
+
*/
|
|
13
|
+
const matrix = loadMatrix();
|
|
14
|
+
|
|
15
|
+
/** Redirect statuses that count as "handled" for the unknown-URL probe. */
|
|
16
|
+
const REDIRECT_STATUSES = [301, 302, 303, 307, 308];
|
|
17
|
+
|
|
18
|
+
for (const route of matrix.routes) {
|
|
19
|
+
test(`00 ${route.kind} ${route.url}`, async ({ request }) => {
|
|
20
|
+
if (route.expect === 404) {
|
|
21
|
+
// Unknown URLs must not render a page: a 404, or a redirect away
|
|
22
|
+
// (e.g. the Redirection plugin's catch-all), both count as handled.
|
|
23
|
+
const res = await request.get(route.url, { maxRedirects: 0 });
|
|
24
|
+
expect(
|
|
25
|
+
[...REDIRECT_STATUSES, 404],
|
|
26
|
+
`unknown URL should 404 or redirect away, got ${res.status()}`
|
|
27
|
+
).toContain(res.status());
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const res = await request.get(route.url, { maxRedirects: 5 });
|
|
32
|
+
expect(res.status(), `status for ${route.url}`).toBe(route.expect);
|
|
33
|
+
|
|
34
|
+
const body = await res.text();
|
|
35
|
+
|
|
36
|
+
// Collect-then-assert so a failure names the signatures found instead of
|
|
37
|
+
// dumping the page body into the report.
|
|
38
|
+
const found = ERROR_SIGNATURES.filter((sig) => body.includes(sig));
|
|
39
|
+
expect(found, `PHP/Twig error signatures in ${route.url}`).toEqual([]);
|
|
40
|
+
|
|
41
|
+
if (route.expect === 200) {
|
|
42
|
+
expect(body, `missing <title> on ${route.url}`).toMatch(/<title>[^<]+<\/title>/i);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Oracle assertions — active when the observer answered (sandbox) and
|
|
46
|
+
// the matrix carries capstan --resolve expectations for this route.
|
|
47
|
+
const headers = res.headers();
|
|
48
|
+
|
|
49
|
+
if (route.template && headers['x-shakedown-template']) {
|
|
50
|
+
expect(headers['x-shakedown-template'], `template oracle for ${route.url}`).toBe(route.template);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (route.controller && headers['x-shakedown-controller']) {
|
|
54
|
+
expect(headers['x-shakedown-controller'], `controller oracle for ${route.url}`).toBe(
|
|
55
|
+
controllerHeaderName(route.controller)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// PHP issues are counted by the observer even when display/log are off —
|
|
60
|
+
// a page can look perfect and still be raising notices on every request.
|
|
61
|
+
if (headers['x-shakedown-php-issues'] !== undefined) {
|
|
62
|
+
const sample = decodeURIComponent(headers['x-shakedown-php-sample'] ?? '');
|
|
63
|
+
expect(Number(headers['x-shakedown-php-issues']), `PHP notices/warnings on ${route.url}: ${sample}`).toBe(0);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { loadMatrix } from './matrix.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pass 01 — integrity.
|
|
6
|
+
*
|
|
7
|
+
* Renders each 200 route in a real browser and asserts a clean runtime:
|
|
8
|
+
* no JS exceptions, no console errors, no failed same-origin requests,
|
|
9
|
+
* and no broken images. Third-party request failures are ignored — the
|
|
10
|
+
* site under trial is the subject, not its CDNs.
|
|
11
|
+
*/
|
|
12
|
+
const matrix = loadMatrix();
|
|
13
|
+
const origin = new URL(matrix.baseUrl).origin;
|
|
14
|
+
|
|
15
|
+
for (const route of matrix.routes.filter((r) => r.expect === 200)) {
|
|
16
|
+
test(`01 ${route.kind} ${route.url}`, async ({ page }) => {
|
|
17
|
+
const pageErrors = [];
|
|
18
|
+
const consoleErrors = [];
|
|
19
|
+
const failedRequests = [];
|
|
20
|
+
|
|
21
|
+
page.on('pageerror', (err) => pageErrors.push(err.message));
|
|
22
|
+
page.on('console', (msg) => {
|
|
23
|
+
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
|
24
|
+
});
|
|
25
|
+
page.on('response', (res) => {
|
|
26
|
+
if (res.status() >= 400 && res.url().startsWith(origin)) {
|
|
27
|
+
failedRequests.push(`${res.status()} ${res.url()}`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await page.goto(route.url, { waitUntil: 'load' });
|
|
32
|
+
|
|
33
|
+
// Lazy images below the fold never load in this pass, so only eagerly
|
|
34
|
+
// loaded, "complete" images with zero natural width count as broken.
|
|
35
|
+
const brokenImages = await page.evaluate(() =>
|
|
36
|
+
Array.from(document.querySelectorAll('img'))
|
|
37
|
+
.filter((img) => img.loading !== 'lazy' && img.complete && img.naturalWidth === 0 && !!img.src)
|
|
38
|
+
.map((img) => img.currentSrc || img.src)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(pageErrors, 'JS exceptions').toEqual([]);
|
|
42
|
+
expect(consoleErrors, 'console errors').toEqual([]);
|
|
43
|
+
expect(failedRequests, 'failed same-origin requests').toEqual([]);
|
|
44
|
+
expect(brokenImages, 'broken images').toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import AxeBuilder from '@axe-core/playwright';
|
|
3
|
+
import { loadMatrix } from './matrix.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pass 02 — accessibility.
|
|
7
|
+
*
|
|
8
|
+
* Runs axe-core against every 200 route, scoped to WCAG 2.1 A/AA rules.
|
|
9
|
+
*
|
|
10
|
+
* Gate policy (per RFC-001: gates start advisory, promoted once stable):
|
|
11
|
+
* `serious` and `critical` violations fail the route; `moderate` and `minor`
|
|
12
|
+
* are reported to the console but do not fail — promote them once the
|
|
13
|
+
* serious/critical set is clean.
|
|
14
|
+
*/
|
|
15
|
+
const matrix = loadMatrix();
|
|
16
|
+
|
|
17
|
+
const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
|
|
18
|
+
const FAILING_IMPACTS = ['serious', 'critical'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One-line summary of an axe violation for readable failure output.
|
|
22
|
+
*
|
|
23
|
+
* @param {{id: string, impact?: string|null, nodes: unknown[], helpUrl: string}} v
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function describeViolation(v) {
|
|
27
|
+
return `[${v.impact}] ${v.id} × ${v.nodes.length} — ${v.helpUrl}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const route of matrix.routes.filter((r) => r.expect === 200)) {
|
|
31
|
+
test(`02 ${route.kind} ${route.url}`, async ({ page }) => {
|
|
32
|
+
await page.goto(route.url, { waitUntil: 'load' });
|
|
33
|
+
|
|
34
|
+
// Iframe contents are excluded: violations inside third-party embeds
|
|
35
|
+
// (YouTube player chrome etc.) are not the site's remediation surface.
|
|
36
|
+
const results = await new AxeBuilder({ page })
|
|
37
|
+
.withTags(WCAG_TAGS)
|
|
38
|
+
.exclude('iframe')
|
|
39
|
+
.analyze();
|
|
40
|
+
|
|
41
|
+
const failing = results.violations.filter((v) => FAILING_IMPACTS.includes(v.impact ?? ''));
|
|
42
|
+
const advisory = results.violations.filter((v) => !FAILING_IMPACTS.includes(v.impact ?? ''));
|
|
43
|
+
|
|
44
|
+
if (advisory.length > 0) {
|
|
45
|
+
console.log(`advisory a11y (${route.url}):\n ${advisory.map(describeViolation).join('\n ')}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
expect(
|
|
49
|
+
failing.map(describeViolation),
|
|
50
|
+
`serious/critical WCAG 2.1 A/AA violations on ${route.url}`
|
|
51
|
+
).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { test, expect } from '@playwright/test';
|
|
4
|
+
import { loadMatrix } from './matrix.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pass 03 — visual regression.
|
|
8
|
+
*
|
|
9
|
+
* Full-page screenshots of every 200 route against baselines committed in
|
|
10
|
+
* the workspace (`tests/__screenshots__/`, per-platform). Deterministic
|
|
11
|
+
* fixtures (seeded values, pinned dates) are what make these stable —
|
|
12
|
+
* a diff should mean the THEME changed, not the content.
|
|
13
|
+
*
|
|
14
|
+
* First run: no baselines exist yet — the whole pass skips with a pointer
|
|
15
|
+
* to `shakedown sandbox --update-snapshots`, so CI isn't red for the wrong
|
|
16
|
+
* reason. `<time>` elements are masked: sample content created by WP core
|
|
17
|
+
* install carries the install date.
|
|
18
|
+
*/
|
|
19
|
+
const matrix = loadMatrix();
|
|
20
|
+
const workspace = process.env.SHAKEDOWN_WORKSPACE ?? process.cwd();
|
|
21
|
+
const hasBaselines = existsSync(join(workspace, 'tests', '__screenshots__'));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stable, unique snapshot name: kind plus the URL path.
|
|
25
|
+
* Mirrored in lib/trial-reporter.mjs — keep in sync.
|
|
26
|
+
*
|
|
27
|
+
* @param {{kind: string, url: string}} route
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
function snapshotName(route) {
|
|
31
|
+
const path = new URL(route.url).pathname.replace(/\W+/g, '-').replace(/^-|-$/g, '') || 'home';
|
|
32
|
+
return `${route.kind.replace(/\W+/g, '-')}--${path}.png`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const route of matrix.routes.filter((r) => r.expect === 200)) {
|
|
36
|
+
test(`03 ${route.kind} ${route.url}`, async ({ page }) => {
|
|
37
|
+
test.skip(!hasBaselines && test.info().config.updateSnapshots === 'missing',
|
|
38
|
+
'No visual baselines yet — create them with: shakedown sandbox --update-snapshots');
|
|
39
|
+
|
|
40
|
+
await page.goto(route.url, { waitUntil: 'load' });
|
|
41
|
+
await page.evaluate(() => document.fonts.ready);
|
|
42
|
+
|
|
43
|
+
await expect(page).toHaveScreenshot(snapshotName(route), {
|
|
44
|
+
fullPage: true,
|
|
45
|
+
animations: 'disabled',
|
|
46
|
+
mask: [page.locator('time')],
|
|
47
|
+
maxDiffPixelRatio: 0.001,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
package/tests/matrix.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} Route One derived route.
|
|
5
|
+
* @property {string} url Absolute URL to test.
|
|
6
|
+
* @property {string} kind `family:detail` label, e.g. `archive:event`.
|
|
7
|
+
* @property {number} expect Intended HTTP status (200, or 404 for the probe).
|
|
8
|
+
* @property {string} [template] Oracle (capstan --resolve): basename of the PHP template WP should choose.
|
|
9
|
+
* @property {string|null} [controller] Oracle: FQCN of the controller that should render (dispatched routes only).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} Matrix The derived matrix written by `shakedown matrix`.
|
|
14
|
+
* @property {string} target
|
|
15
|
+
* @property {string} baseUrl
|
|
16
|
+
* @property {Route[]} routes
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load the persisted matrix from the workspace (the directory the CLI was
|
|
21
|
+
* invoked from), with a friendly nudge when it hasn't been derived.
|
|
22
|
+
*
|
|
23
|
+
* @returns {Matrix}
|
|
24
|
+
*/
|
|
25
|
+
export function loadMatrix() {
|
|
26
|
+
const path = process.env.SHAKEDOWN_WORKSPACE
|
|
27
|
+
? `${process.env.SHAKEDOWN_WORKSPACE}/.shakedown/matrix.json`
|
|
28
|
+
: new URL('../.shakedown/matrix.json', import.meta.url);
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error('No matrix found. Run `shakedown matrix` (or `npm run matrix`) first.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The observer reports controllers as the snake_case short name used in the
|
|
38
|
+
* pressgang_render_{key} action; the oracle stores an FQCN. Normalise the
|
|
39
|
+
* FQCN to the observer's shape for comparison.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} fqcn
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function controllerHeaderName(fqcn) {
|
|
45
|
+
const short = fqcn.split('\\').pop() ?? fqcn;
|
|
46
|
+
return short.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Signatures of PHP/Twig failure output in a rendered page body.
|
|
51
|
+
*
|
|
52
|
+
* Covers both plain-text (CLI/log style) and PHP's HTML display format
|
|
53
|
+
* (`<b>Warning</b>:`). Trade-off: a page whose *content* legitimately contains
|
|
54
|
+
* one of these strings will false-positive — accepted, because a marketing
|
|
55
|
+
* page reading "Fatal error" deserves a human look anyway.
|
|
56
|
+
*/
|
|
57
|
+
export const ERROR_SIGNATURES = [
|
|
58
|
+
'Fatal error',
|
|
59
|
+
'Parse error',
|
|
60
|
+
'Warning: ',
|
|
61
|
+
'Notice: ',
|
|
62
|
+
'Deprecated: ',
|
|
63
|
+
'<b>Fatal error</b>',
|
|
64
|
+
'<b>Warning</b>:',
|
|
65
|
+
'<b>Notice</b>:',
|
|
66
|
+
'<b>Deprecated</b>:',
|
|
67
|
+
'Uncaught Error',
|
|
68
|
+
'Twig\\Error',
|
|
69
|
+
'Stack trace:',
|
|
70
|
+
];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { seedAcfStates } from '../../lib/sandbox.mjs';
|
|
7
|
+
|
|
8
|
+
test('seedAcfStates passes the configured seed and epoch to Muster', () => {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'shakedown-seed-test-'));
|
|
10
|
+
const theme = 'fixture-theme';
|
|
11
|
+
const acfJson = join(root, 'wp-content', 'themes', theme, 'acf-json');
|
|
12
|
+
const fakeBin = join(root, 'bin');
|
|
13
|
+
const capture = join(root, 'wp-args.txt');
|
|
14
|
+
const fakeWp = join(fakeBin, 'wp');
|
|
15
|
+
const oldPath = process.env.PATH;
|
|
16
|
+
const oldCapture = process.env.SHAKEDOWN_WP_CAPTURE;
|
|
17
|
+
|
|
18
|
+
mkdirSync(acfJson, { recursive: true });
|
|
19
|
+
mkdirSync(fakeBin, { recursive: true });
|
|
20
|
+
writeFileSync(fakeWp, `#!/bin/sh
|
|
21
|
+
printf '%s\n' "$@" > "$SHAKEDOWN_WP_CAPTURE"
|
|
22
|
+
printf '{"routes":[]}'
|
|
23
|
+
`);
|
|
24
|
+
chmodSync(fakeWp, 0o755);
|
|
25
|
+
process.env.PATH = `${fakeBin}:${oldPath}`;
|
|
26
|
+
process.env.SHAKEDOWN_WP_CAPTURE = capture;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const routes = seedAcfStates(
|
|
30
|
+
{ root, theme },
|
|
31
|
+
'/tmp/muster/vendor/autoload.php',
|
|
32
|
+
{ seed: 1978, epoch: '2030-04-05T06:07:08+00:00' }
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(routes, []);
|
|
36
|
+
const args = readFileSync(capture, 'utf8').trim().split('\n');
|
|
37
|
+
assert.equal(args[0], 'eval-file');
|
|
38
|
+
assert.equal(args[2], '/tmp/muster/vendor/autoload.php');
|
|
39
|
+
assert.equal(args[3], acfJson);
|
|
40
|
+
assert.equal(args[4], '1978');
|
|
41
|
+
assert.equal(args[5], '2030-04-05T06:07:08+00:00');
|
|
42
|
+
assert.equal(args[6], `--path=${root}`);
|
|
43
|
+
} finally {
|
|
44
|
+
process.env.PATH = oldPath;
|
|
45
|
+
if (oldCapture === undefined) delete process.env.SHAKEDOWN_WP_CAPTURE;
|
|
46
|
+
else process.env.SHAKEDOWN_WP_CAPTURE = oldCapture;
|
|
47
|
+
rmSync(root, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('seedAcfStates rejects ambiguous deterministic inputs', () => {
|
|
52
|
+
assert.throws(
|
|
53
|
+
() => seedAcfStates({ root: '/tmp', theme: 'theme' }, '/tmp/autoload.php', { seed: '42' }),
|
|
54
|
+
/seed must be an integer/
|
|
55
|
+
);
|
|
56
|
+
assert.throws(
|
|
57
|
+
() => seedAcfStates({ root: '/tmp', theme: 'theme' }, '/tmp/autoload.php', { epoch: '' }),
|
|
58
|
+
/timezone-qualified ISO 8601/
|
|
59
|
+
);
|
|
60
|
+
assert.throws(
|
|
61
|
+
() => seedAcfStates(
|
|
62
|
+
{ root: '/tmp', theme: 'theme' },
|
|
63
|
+
'/tmp/autoload.php',
|
|
64
|
+
{ epoch: '2030-04-05T06:07:08' }
|
|
65
|
+
),
|
|
66
|
+
/timezone-qualified ISO 8601/
|
|
67
|
+
);
|
|
68
|
+
});
|