@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 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
 
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.0",
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
+ }
@@ -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
+ });