@pressgang-wp/shakedown 0.1.0 → 0.1.2

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 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 github:pressgang-wp/pressgang-shakedown
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) | Seeds deterministic populated/minimal ACF states in the disposable sandbox |
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
 
@@ -4,11 +4,16 @@
4
4
  * the sandbox isolation witness has already proven this WordPress runs on
5
5
  * throwaway SQLite before this script is invoked).
6
6
  *
7
- * For every ACF field group with a seedable location target, creates two
8
- * published fixtures via Muster:
9
- * - populated: every generatable field filled (deterministic, seeded)
10
- * - minimal: required fields only — the sparsest state an editor can
11
- * legally publish, where empty-link/missing-image bugs live
7
+ * Each ACF field group is seeded according to where it is located:
8
+ * - post types, page and post templates → a dedicated PAIR of fixtures,
9
+ * `populated` (every generatable field filled) and `minimal` (required
10
+ * fields only — the sparsest publishable state, where empty-link and
11
+ * missing-image bugs live), each with a placeholder featured image;
12
+ * - options pages → the populated values written once (global chrome);
13
+ * - the front page (`page_type=front_page`) → the populated values onto the
14
+ * home page, reusing a theme-assigned front page or creating one;
15
+ * - nav-menu-item groups → left to the theme's own seeder, whose menus carry
16
+ * the real structure the fields attach to.
12
17
  *
13
18
  * Run via: wp eval-file bin/seed-states.php <muster-autoload> <acf-json-dir> <seed> <epoch>
14
19
  * Emits JSON: {"routes": [{url, kind, expect}...]} for the matrix.
@@ -51,13 +56,23 @@ $generator = new AcfValueGenerator($context->victuals(), [
51
56
  ->id(),
52
57
  'post' => function (array $types) use ($context): int {
53
58
  $type = $types[0] ?? 'post';
59
+ $slug = 'state-related-' . sanitize_title($type);
54
60
 
55
- return (new PostBuilder($context, $type))
61
+ $ref = (new PostBuilder($context, $type))
56
62
  ->title('State related ' . $type)
57
- ->slug('state-related-' . sanitize_title($type))
63
+ ->slug($slug)
58
64
  ->status('publish')
59
- ->save()
60
- ->id();
65
+ // Pin before the fixture epoch so relationship stubs sink below the
66
+ // real fixtures in date-ordered feeds rather than heading them.
67
+ ->date($context->clock()->epoch()->modify('-1 year')->format('Y-m-d H:i:s'))
68
+ ->save();
69
+
70
+ (new AttachmentBuilder($context, "{$slug}-thumb"))
71
+ ->placeholder(1200, 800)
72
+ ->featuredOn($ref)
73
+ ->save();
74
+
75
+ return $ref->id();
61
76
  },
62
77
  'term' => fn (string $taxonomy): int => (new TermBuilder($context, $taxonomy))
63
78
  ->name('State term')
@@ -68,43 +83,90 @@ $generator = new AcfValueGenerator($context->victuals(), [
68
83
  ]);
69
84
 
70
85
  $routes = [];
86
+ $liveAcf = new LiveAcfAdapter();
71
87
 
72
88
  foreach (AcfJson::groups($acfJsonDir) as $group) {
73
89
  $targets = AcfJson::targets($group);
74
90
 
75
91
  if ($targets === []) {
76
- continue; // menu items, page_type rules not seedable (v1)
92
+ continue; // no seedable location (taxonomy terms, user roles, )
77
93
  }
78
94
 
79
95
  $target = $targets[0];
96
+ $fields = (array) $group['fields'];
97
+ $slugBase = sanitize_title($group['title'] ?? $group['key']);
80
98
 
81
99
  // Options-page groups are global state, not a URL: seed the populated
82
- // values once so chrome (header/footer) renders fully. The unseeded
83
- // fresh install already exercises the empty-options state.
100
+ // values once so chrome (header/footer) renders fully. The unseeded fresh
101
+ // install already exercises the empty-options state.
84
102
  if ($target['param'] === 'options_page') {
85
- (new LiveAcfAdapter())->updateFields($generator->populated((array) $group['fields']), 'option', 0);
103
+ $liveAcf->updateFields($generator->populated($fields), 'option', 0);
86
104
  continue;
87
105
  }
88
- $slugBase = sanitize_title($group['title'] ?? $group['key']);
89
106
 
107
+ // The front page is a singular surface — seed the populated state onto the
108
+ // one home page. Reuse the front page a theme seeder may already have set;
109
+ // assign one only when none exists, so we never fight a theme-authored home.
110
+ // The matrix's own `/` route covers it, so no dedicated route is emitted.
111
+ // Other page_type values (top_level, …) have no generic surface to seed.
112
+ if ($target['param'] === 'page_type') {
113
+ if ($target['value'] !== 'front_page') {
114
+ continue;
115
+ }
116
+
117
+ $frontId = (int) get_option('page_on_front');
118
+ if ($frontId === 0) {
119
+ $frontId = (new PostBuilder($context, 'page'))
120
+ ->title('Home')
121
+ ->slug('state-front-page')
122
+ ->status('publish')
123
+ ->date($fixtureDate)
124
+ ->save()
125
+ ->id();
126
+ update_option('show_on_front', 'page');
127
+ update_option('page_on_front', $frontId);
128
+ }
129
+
130
+ $liveAcf->updateFields($generator->populated($fields), 'post', $frontId);
131
+ continue;
132
+ }
133
+
134
+ // nav_menu_item groups attach to a menu's items, which exist only once a
135
+ // menu is built with the theme's real structure — that is the theme
136
+ // seeder's job. The derived path leaves them to the baseline rather than
137
+ // inventing a menu here.
138
+ if ($target['param'] === 'nav_menu_item') {
139
+ continue;
140
+ }
141
+
142
+ // Per-instance surfaces (post types, page and post templates): a dedicated
143
+ // populated AND minimal fixture, so the sparsest publishable state is
144
+ // exercised alongside the full one.
90
145
  foreach (['populated', 'minimal'] as $variant) {
91
- $fields = (array) $group['fields'];
92
146
  $values = $variant === 'populated' ? $generator->populated($fields) : $generator->minimal($fields);
93
147
 
94
- $builder = $target['param'] === 'page_template'
95
- ? (new PostBuilder($context, 'page'))->template($target['value'])
96
- : new PostBuilder($context, $target['value']);
148
+ $builder = match ($target['param']) {
149
+ 'page_template' => (new PostBuilder($context, 'page'))->template($target['value']),
150
+ 'post_template' => (new PostBuilder($context, 'post'))->template($target['value']),
151
+ default => new PostBuilder($context, $target['value']),
152
+ };
97
153
 
98
154
  $ref = $builder
99
155
  ->title(($group['title'] ?? $group['key']) . ' — ' . $variant)
100
156
  ->slug("state-{$slugBase}-{$variant}")
101
157
  ->status('publish')
102
158
  ->date($fixtureDate)
103
-
104
159
  ->content('State fixture: ' . $slugBase . ' (' . $variant . ')')
105
160
  ->acf($values)
106
161
  ->save();
107
162
 
163
+ // A placeholder featured image so archive and card thumbnails render
164
+ // instead of the theme's empty-thumbnail fallback.
165
+ (new AttachmentBuilder($context, "state-thumb-{$slugBase}-{$variant}"))
166
+ ->placeholder(1200, 800)
167
+ ->featuredOn($ref)
168
+ ->save();
169
+
108
170
  $url = get_permalink($ref->id());
109
171
 
110
172
  if ($url) {
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
@@ -285,6 +285,12 @@ export async function bootSandbox(target, options = {}) {
285
285
  }
286
286
  }
287
287
 
288
+ // Clear WordPress's install defaults (Hello world!, Sample Page, the seeded
289
+ // comment) so the sandbox starts from a deterministic empty state — only
290
+ // seeded fixtures exist, and no version-dependent defaults leak into feeds,
291
+ // archives, or menus.
292
+ wp(root, ['site', 'empty', '--yes']);
293
+
288
294
  wp(root, ['rewrite', 'structure', '/%postname%/', '--hard']);
289
295
 
290
296
  const server = spawn('wp', ['server', `--host=127.0.0.1`, `--port=${port}`, `--docroot=${docroot}`, `--path=${root}`], {
@@ -306,6 +312,47 @@ export async function bootSandbox(target, options = {}) {
306
312
  };
307
313
  }
308
314
 
315
+ /**
316
+ * Convention-first baseline seeding: run the theme's OWN Muster seeders
317
+ * (anything under its `src/Muster/`) via `wp capstan seed`, so a theme that
318
+ * ships fixtures gets its real content — menus assigned to locations, terms,
319
+ * pages, relationships — exactly as `wp capstan seed` produces on a dev site.
320
+ * The ACF state fixtures (see {@link seedAcfStates}) then layer on top.
321
+ *
322
+ * This is a deliberate no-op — returning false so the caller falls back to
323
+ * deriving everything from acf-json — when the theme ships no Muster classes
324
+ * or Capstan isn't installed to discover them. Out of the box, a theme with no
325
+ * fixtures still gets a fully derived suite; a theme with fixtures gets its own.
326
+ *
327
+ * @param {{root: string, theme: string|null}} sandbox
328
+ * @param {{seed?: number}} options Deterministic random seed passed through to Muster.
329
+ * @returns {boolean} True when theme seeders ran; false when none were applied.
330
+ */
331
+ export function seedThemeMuster(sandbox, { seed = 42 } = {}) {
332
+ const musterDir = join(sandbox.root, 'wp-content', 'themes', sandbox.theme ?? '', 'src', 'Muster');
333
+
334
+ if (!sandbox.theme || !existsSync(musterDir)) {
335
+ return false;
336
+ }
337
+
338
+ try {
339
+ // `wp capstan seed` is Capstan's discovery-and-run of the theme's Muster
340
+ // subclasses; probe for it so a missing Capstan degrades to the fallback
341
+ // rather than throwing.
342
+ execFileSync('wp', ['cli', 'has-command', 'capstan seed', `--path=${sandbox.root}`], { stdio: 'ignore' });
343
+ } catch {
344
+ return false;
345
+ }
346
+
347
+ execFileSync(
348
+ 'wp',
349
+ ['capstan', 'seed', `--seed=${seed}`, `--path=${sandbox.root}`],
350
+ { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
351
+ );
352
+
353
+ return true;
354
+ }
355
+
309
356
  /**
310
357
  * Seed ACF state fixtures (populated + minimal per field group) inside a
311
358
  * 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.0",
3
+ "version": "0.1.2",
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
+ }
@@ -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
+ });