@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benedict
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # 🚢 Shakedown
2
+
3
+ **Shakedown** is end-to-end browser testing for **PressGang WordPress themes** — with **zero tests to write**.
4
+
5
+ A shakedown cruise is the sea trial of a new vessel: take her out, push every system, find what rattles before the passengers board. Shakedown does the same for your theme. Because PressGang themes declare their post types, taxonomies, templates and menus in `config/`, Shakedown can **derive the whole test suite from the site itself** — enumerate every route, then check each one in a real browser.
6
+
7
+ Point it at a running site and in under a minute you'll know: does every page render, error-free, with nothing broken aboard? ⛵
8
+
9
+ ---
10
+
11
+ ## ⚡ Quick start
12
+
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
+
15
+ ```bash
16
+ npm i -D github:pressgang-wp/pressgang-shakedown
17
+ npx playwright install chromium # once per machine
18
+ npx shakedown # ⚓ derive the matrix, run every pass
19
+ ```
20
+
21
+ That's it. Shakedown walks up from your theme to find `wp-config.php`, asks WP-CLI for the site URL, enumerates every route, and checks them all — **no config, no specs written**.
22
+
23
+ Other commands:
24
+
25
+ ```bash
26
+ npx shakedown matrix # 🗺️ just enumerate and print the routes
27
+ npx shakedown test # 🧪 run passes against the existing matrix
28
+ npx shakedown ui # Playwright's watch/UI mode
29
+ npx playwright show-report # browse the last run
30
+ ```
31
+
32
+ Optional `shakedown.config.json` in the theme, for overrides only:
33
+
34
+ ```json
35
+ {
36
+ "baseUrl": "https://mysite.test",
37
+ "samplesPerType": 2,
38
+ "searchTerm": "bikes",
39
+ "sandbox": {
40
+ "seed": 42,
41
+ "epoch": "2026-01-01T09:00:00+00:00"
42
+ }
43
+ }
44
+ ```
45
+
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
+
48
+ Sandbox fixture randomness and time are separate deterministic inputs. `seed`
49
+ controls generated values; `epoch` fixes relative dates used by Muster,
50
+ including ACF date fields and the fixture posts themselves. It must be a
51
+ timezone-qualified ISO 8601 datetime.
52
+
53
+ **Introspection:** when [Capstan](https://github.com/pressgang-wp/pressgang-capstan) is installed (`wp package install pressgang-wp/pressgang-capstan`), the matrix comes from `wp capstan matrix --resolve` — including each route's expected template and controller. Without it, a bundled fallback derives the same routes minus the oracle data.
54
+
55
+ **Central mode** (many sites from one clone): a `shakedown.config.json` with a `targets` map lets one checkout drive any registered site — `npx shakedown --target=mysite`.
56
+
57
+ ---
58
+
59
+ ## 🗺️ What gets tested
60
+
61
+ `npm run matrix` asks the **running site** (via WP-CLI) for everything it serves:
62
+
63
+ | Derived from | Routes |
64
+ | --- | --- |
65
+ | Front page | `/` |
66
+ | Every public post type | its archive + sample singles |
67
+ | Every public taxonomy | sample term pages |
68
+ | Page templates | every published page using one |
69
+ | Menus | every internal menu target |
70
+ | Search | `/?s=…` |
71
+ | Unknown URLs | a 404 probe |
72
+
73
+ Add a post type to your theme's `config/custom-post-types.php` and the next run covers it automatically. The matrix is the map; the passes are the inspection. 🔦
74
+
75
+ ## 🧪 The passes
76
+
77
+ | Pass | Speed | Checks |
78
+ | --- | --- | --- |
79
+ | **00 · Availability** | ~seconds (HTTP only) | Every route returns its intended status · no PHP/Twig error signatures in the body · a `<title>` is present. The 404 probe accepts a 404 *or* a redirect-away (Redirection-plugin catch-alls are fine). |
80
+ | **01 · Integrity** | ~seconds–minutes (real Chromium) | No JS exceptions · no console errors · no failed same-origin requests · no broken images. |
81
+
82
+ Useful variations:
83
+
84
+ ```bash
85
+ npm test -- tests/00-availability.spec.ts # just the fast pass
86
+ npm run test:ui # Playwright's watch/UI mode
87
+ npm run matrix -- othersite # derive for a named target
88
+ npx playwright show-report # browse the last run's HTML report
89
+ ```
90
+
91
+ When something fails you get the exact URL, what was expected, and a Playwright **trace** you can replay step-by-step. 🔍
92
+
93
+ ---
94
+
95
+ ## 💡 Good to know
96
+
97
+ - **Read-only by design.** The passes only ever GET pages — safe to run against any environment you can reach.
98
+ - **Testing a live shared server?** Runs are parallel; a single retry is built in to absorb load transients on one PHP-FPM.
99
+ - **Self-signed `.test` certificates** are already handled (`ignoreHTTPSErrors`).
100
+ - **True story:** on its very first run, Shakedown found a real bug — category archives returning `200` with an empty body. Zero tests written. That's the pitch. 🐛
101
+
102
+ ---
103
+
104
+ ## 🤖 CI
105
+
106
+ A reusable GitHub Actions workflow runs the full sandbox suite on every push — no MySQL, no Docker, no site bundle. The theme repo is the only input: WordPress core is downloaded bare, your `composer.json`'s installer-paths provision the parent theme and plugins, and the sandbox brings its own SQLite database and ACF state fixtures.
107
+
108
+ In your theme repo, `.github/workflows/shakedown.yml`:
109
+
110
+ ```yaml
111
+ name: Shakedown
112
+ on: [push, pull_request]
113
+ jobs:
114
+ shakedown:
115
+ uses: pressgang-wp/pressgang-shakedown/.github/workflows/shakedown.yml@main
116
+ secrets:
117
+ COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} # ACF Pro credentials, if composer-managed
118
+ ```
119
+
120
+ Inputs (all optional): `theme` (defaults to the repo name), `php-version`,
121
+ `wp-version`, `node-version`, and `muster-ref`. The workflow pins `muster-ref` to
122
+ the exact fixture engine revision it was verified against; override it only as
123
+ an intentional compatibility test. The Playwright HTML report and route matrix
124
+ upload as artifacts on every run. Suits theme-shaped repos; site-shaped repos
125
+ work too once their theme path is passed as `theme`. 🧪
126
+
127
+ ## ⚓ The PressGang fleet
128
+
129
+ Shakedown is part of the [PressGang](https://pressgang.dev) ecosystem and is designed to compose with its shipmates:
130
+
131
+ | Package | Role |
132
+ | --- | --- |
133
+ | [pressgang](https://github.com/pressgang-wp/pressgang) | The parent theme framework (Timber + Twig, config-driven) |
134
+ | [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 |
136
+ | [bosun](https://github.com/pressgang-wp/pressgang-bosun) | AI-agent guidelines & skills — future distribution channel for Shakedown's QA skills |
137
+
138
+ ## 🛠️ Roadmap
139
+
140
+ - `wp capstan matrix --format=json` + **oracle assertions** — assert each URL rendered via its *intended* controller and Twig template, catching silent fallbacks to `index.php`
141
+ - **Observer mu-plugin** — PHP notice capture and render telemetry (template/snippet coverage)
142
+ - More passes: **accessibility** (axe-core), **visual snapshots**
143
+ - **Trial Report** — a client-readable HTML report with screenshots and coverage
144
+ - Engines: self-booting **WordPress Playground**, per-PR **InstaWP** CI sites, **wp-env** fidelity lane
145
+ - Published as `@pressgang-wp/shakedown` on npm
146
+
147
+ ## 📋 Requirements
148
+
149
+ - Node 20+
150
+ - WP-CLI on your PATH
151
+ - A locally reachable PressGang (or any WordPress) site — the derived passes are actually framework-agnostic; PressGang is where the deeper introspection is headed
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Thin wrapper kept for `npm run matrix` in this repo.
4
+ * The real logic lives in lib/target.mjs and lib/derive.mjs;
5
+ * the `shakedown` CLI (bin/shakedown.mjs) is the primary entry point.
6
+ *
7
+ * Usage: node bin/derive-matrix.mjs [target]
8
+ */
9
+ import { resolveTarget } from '../lib/target.mjs';
10
+ import { deriveMatrix } from '../lib/derive.mjs';
11
+
12
+ const target = resolveTarget(process.cwd(), { target: process.argv[2] });
13
+ const { matrix, source, path } = deriveMatrix(target, process.cwd());
14
+
15
+ console.log(`Matrix for "${target.name}": ${matrix.routes.length} routes (via ${source}) → ${path}`);
16
+ for (const r of matrix.routes) {
17
+ console.log(` [${r.expect}] ${r.kind.padEnd(28)} ${r.url}`);
18
+ }
package/bin/matrix.php ADDED
@@ -0,0 +1,166 @@
1
+ <?php
2
+ /**
3
+ * Emits the shakedown route matrix for the current site as JSON.
4
+ *
5
+ * Runs inside WordPress via `wp eval-file bin/matrix.php <samplesPerType> <searchTerm>`
6
+ * (invoked by bin/derive-matrix.mjs, which supplies the args from shakedown.config.json).
7
+ *
8
+ * Route shape: `['url' => string, 'kind' => string, 'expect' => int]` where
9
+ * `kind` is a `family:detail` label (`archive:event`, `template:contact-page.php`, …)
10
+ * and `expect` is the intended HTTP status.
11
+ *
12
+ * Functions are prefixed `shakedown_` because eval-file executes in global scope.
13
+ */
14
+
15
+ /**
16
+ * Append a route unless the URL is empty or a WP_Error.
17
+ *
18
+ * @param array<int, array<string, mixed>> $routes
19
+ * @param string|\WP_Error|false $url
20
+ * @param string $kind
21
+ * @param int $expect
22
+ * @return void
23
+ */
24
+ function shakedown_add( array &$routes, $url, string $kind, int $expect = 200 ): void {
25
+ if ( $url && ! is_wp_error( $url ) ) {
26
+ $routes[] = [ 'url' => $url, 'kind' => $kind, 'expect' => $expect ];
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Archive link plus sample singles for every public post type.
32
+ *
33
+ * @param int $samples Published singles to include per type.
34
+ * @return array<int, array<string, mixed>>
35
+ */
36
+ function shakedown_post_type_routes( int $samples ): array {
37
+ $routes = [];
38
+
39
+ foreach ( get_post_types( [ 'public' => true ], 'objects' ) as $pt ) {
40
+ if ( $pt->has_archive ) {
41
+ shakedown_add( $routes, get_post_type_archive_link( $pt->name ), "archive:{$pt->name}" );
42
+ }
43
+
44
+ $posts = get_posts( [ 'post_type' => $pt->name, 'numberposts' => $samples, 'post_status' => 'publish' ] );
45
+ foreach ( $posts as $p ) {
46
+ shakedown_add( $routes, get_permalink( $p ), "single:{$pt->name}" );
47
+ }
48
+ }
49
+
50
+ return $routes;
51
+ }
52
+
53
+ /**
54
+ * Sample term pages for every public taxonomy (non-empty terms only).
55
+ *
56
+ * @return array<int, array<string, mixed>>
57
+ */
58
+ function shakedown_taxonomy_routes(): array {
59
+ $routes = [];
60
+
61
+ foreach ( get_taxonomies( [ 'public' => true ], 'names' ) as $tax ) {
62
+ $terms = get_terms( [ 'taxonomy' => $tax, 'number' => 3, 'hide_empty' => true ] );
63
+ if ( is_wp_error( $terms ) ) {
64
+ continue;
65
+ }
66
+ foreach ( $terms as $t ) {
67
+ shakedown_add( $routes, get_term_link( $t ), "term:{$tax}" );
68
+ }
69
+ }
70
+
71
+ return $routes;
72
+ }
73
+
74
+ /**
75
+ * Every published page assigned a non-default page template.
76
+ *
77
+ * These exercise the theme's registered templates end-to-end, keyed by
78
+ * template slug so failures name the template, not just the page.
79
+ *
80
+ * @return array<int, array<string, mixed>>
81
+ */
82
+ function shakedown_template_routes(): array {
83
+ $routes = [];
84
+
85
+ $pages = get_posts( [
86
+ 'post_type' => 'page',
87
+ 'numberposts' => -1,
88
+ 'post_status' => 'publish',
89
+ 'meta_key' => '_wp_page_template',
90
+ ] );
91
+
92
+ foreach ( $pages as $p ) {
93
+ $tpl = get_page_template_slug( $p );
94
+ if ( $tpl && 'default' !== $tpl ) {
95
+ shakedown_add( $routes, get_permalink( $p ), "template:{$tpl}" );
96
+ }
97
+ }
98
+
99
+ return $routes;
100
+ }
101
+
102
+ /**
103
+ * Internal targets of every registered nav menu (external links are skipped —
104
+ * shakedown only tests the site under trial).
105
+ *
106
+ * @return array<int, array<string, mixed>>
107
+ */
108
+ function shakedown_menu_routes(): array {
109
+ $routes = [];
110
+
111
+ foreach ( wp_get_nav_menus() as $menu ) {
112
+ $items = wp_get_nav_menu_items( $menu ) ?: [];
113
+ foreach ( $items as $item ) {
114
+ if ( 0 === strpos( (string) $item->url, home_url() ) ) {
115
+ shakedown_add( $routes, $item->url, "menu:{$menu->slug}" );
116
+ }
117
+ }
118
+ }
119
+
120
+ return $routes;
121
+ }
122
+
123
+ /**
124
+ * Drop duplicate URLs, keeping the first (most specific) kind label.
125
+ *
126
+ * @param array<int, array<string, mixed>> $routes
127
+ * @return array<int, array<string, mixed>>
128
+ */
129
+ function shakedown_dedupe( array $routes ): array {
130
+ $seen = [];
131
+
132
+ return array_values( array_filter( $routes, function ( array $r ) use ( &$seen ): bool {
133
+ if ( isset( $seen[ $r['url'] ] ) ) {
134
+ return false;
135
+ }
136
+ $seen[ $r['url'] ] = true;
137
+
138
+ return true;
139
+ } ) );
140
+ }
141
+
142
+ $samples = isset( $args[0] ) ? (int) $args[0] : 2;
143
+ $search_term = isset( $args[1] ) ? (string) $args[1] : 'test';
144
+
145
+ $routes = [];
146
+ shakedown_add( $routes, home_url( '/' ), 'home' );
147
+
148
+ $routes = array_merge(
149
+ $routes,
150
+ shakedown_post_type_routes( $samples ),
151
+ shakedown_taxonomy_routes(),
152
+ shakedown_template_routes(),
153
+ shakedown_menu_routes(),
154
+ );
155
+
156
+ shakedown_add( $routes, home_url( '/?s=' . rawurlencode( $search_term ) ), 'search' );
157
+ shakedown_add( $routes, home_url( '/shakedown-404-probe' ), '404', 404 );
158
+
159
+ echo json_encode(
160
+ [
161
+ 'generated' => gmdate( 'c' ),
162
+ 'home' => home_url( '/' ),
163
+ 'routes' => shakedown_dedupe( $routes ),
164
+ ],
165
+ JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
166
+ );
@@ -0,0 +1,116 @@
1
+ <?php
2
+ /**
3
+ * Seeds ACF state fixtures inside a shakedown SANDBOX (never a real site —
4
+ * the sandbox isolation witness has already proven this WordPress runs on
5
+ * throwaway SQLite before this script is invoked).
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
12
+ *
13
+ * Run via: wp eval-file bin/seed-states.php <muster-autoload> <acf-json-dir> <seed> <epoch>
14
+ * Emits JSON: {"routes": [{url, kind, expect}...]} for the matrix.
15
+ */
16
+
17
+ if (count($args) < 4) {
18
+ throw new InvalidArgumentException('seed-states.php requires Muster autoload, ACF JSON directory, seed, and epoch arguments.');
19
+ }
20
+
21
+ [$autoload, $acfJsonDir, $seed, $epoch] = [$args[0], $args[1], (int) $args[2], (string) $args[3]];
22
+
23
+ if (! preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i', $epoch)) {
24
+ throw new InvalidArgumentException('Fixture epoch must be a timezone-qualified ISO 8601 datetime.');
25
+ }
26
+
27
+ require_once $autoload;
28
+
29
+ use PressGang\Muster\Acf\AcfJson;
30
+ use PressGang\Muster\Acf\AcfValueGenerator;
31
+ use PressGang\Muster\Adapters\LiveAcfAdapter;
32
+ use PressGang\Muster\Builders\AttachmentBuilder;
33
+ use PressGang\Muster\Builders\PostBuilder;
34
+ use PressGang\Muster\Builders\TermBuilder;
35
+ use PressGang\Muster\Clock\FixtureClock;
36
+ use PressGang\Muster\MusterContext;
37
+ use PressGang\Muster\Victuals\VictualsFactory;
38
+
39
+ $context = new MusterContext(
40
+ new VictualsFactory(),
41
+ acf: new LiveAcfAdapter(),
42
+ seed: $seed,
43
+ clock: new FixtureClock($epoch),
44
+ );
45
+ $fixtureDate = $context->clock()->epoch()->format('Y-m-d H:i:s');
46
+
47
+ $generator = new AcfValueGenerator($context->victuals(), [
48
+ 'attachment' => fn (string $name): int => (new AttachmentBuilder($context, 'state-' . sanitize_title($name)))
49
+ ->placeholder(1200, 800)
50
+ ->save()
51
+ ->id(),
52
+ 'post' => function (array $types) use ($context): int {
53
+ $type = $types[0] ?? 'post';
54
+
55
+ return (new PostBuilder($context, $type))
56
+ ->title('State related ' . $type)
57
+ ->slug('state-related-' . sanitize_title($type))
58
+ ->status('publish')
59
+ ->save()
60
+ ->id();
61
+ },
62
+ 'term' => fn (string $taxonomy): int => (new TermBuilder($context, $taxonomy))
63
+ ->name('State term')
64
+ ->slug('state-term')
65
+ ->save()
66
+ ->termId(),
67
+ 'user' => fn (): int => 1,
68
+ ]);
69
+
70
+ $routes = [];
71
+
72
+ foreach (AcfJson::groups($acfJsonDir) as $group) {
73
+ $targets = AcfJson::targets($group);
74
+
75
+ if ($targets === []) {
76
+ continue; // menu items, page_type rules — not seedable (v1)
77
+ }
78
+
79
+ $target = $targets[0];
80
+
81
+ // 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.
84
+ if ($target['param'] === 'options_page') {
85
+ (new LiveAcfAdapter())->updateFields($generator->populated((array) $group['fields']), 'option', 0);
86
+ continue;
87
+ }
88
+ $slugBase = sanitize_title($group['title'] ?? $group['key']);
89
+
90
+ foreach (['populated', 'minimal'] as $variant) {
91
+ $fields = (array) $group['fields'];
92
+ $values = $variant === 'populated' ? $generator->populated($fields) : $generator->minimal($fields);
93
+
94
+ $builder = $target['param'] === 'page_template'
95
+ ? (new PostBuilder($context, 'page'))->template($target['value'])
96
+ : new PostBuilder($context, $target['value']);
97
+
98
+ $ref = $builder
99
+ ->title(($group['title'] ?? $group['key']) . ' — ' . $variant)
100
+ ->slug("state-{$slugBase}-{$variant}")
101
+ ->status('publish')
102
+ ->date($fixtureDate)
103
+
104
+ ->content('State fixture: ' . $slugBase . ' (' . $variant . ')')
105
+ ->acf($values)
106
+ ->save();
107
+
108
+ $url = get_permalink($ref->id());
109
+
110
+ if ($url) {
111
+ $routes[] = [ 'url' => $url, 'kind' => "state:{$slugBase}:{$variant}", 'expect' => 200 ];
112
+ }
113
+ }
114
+ }
115
+
116
+ echo json_encode([ 'routes' => $routes ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * shakedown — sea trials for PressGang themes.
4
+ *
5
+ * Run from inside a theme (or anywhere in a WordPress project):
6
+ *
7
+ * shakedown derive the matrix, then run every pass
8
+ * shakedown matrix derive and print the route matrix only
9
+ * shakedown test [...] run passes (extra args pass through to Playwright)
10
+ * shakedown ui Playwright UI mode
11
+ *
12
+ * The invocation directory is the workspace: matrix, reports, and traces are
13
+ * written there, and its tests/e2e/ (if present) runs as the journeys suite.
14
+ */
15
+ import { spawnSync } from 'node:child_process';
16
+ import { createRequire } from 'node:module';
17
+ import { dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { existsSync } from 'node:fs';
20
+ import { resolveTarget } from '../lib/target.mjs';
21
+ import { capstanDoctor, deriveMatrix, mergeRoutes } from '../lib/derive.mjs';
22
+ import { bootSandbox, DEFAULT_FIXTURE_EPOCH, seedAcfStates } from '../lib/sandbox.mjs';
23
+
24
+ const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)));
25
+ const workspace = process.cwd();
26
+
27
+ const argv = process.argv.slice(2);
28
+ const targetFlag = (argv.find((a) => a.startsWith('--target=')) || '').split('=')[1];
29
+ const args = argv.filter((a) => !a.startsWith('--target='));
30
+ const command = args[0] ?? 'all';
31
+
32
+ /**
33
+ * Locate Muster's autoloader for sandbox seeding: explicit config first,
34
+ * then the theme's own composer vendor, then a sibling checkout of the
35
+ * pressgang-muster repo (fleet-dev convenience).
36
+ *
37
+ * @param {ReturnType<import('../lib/target.mjs').resolveTarget>} target
38
+ * @returns {string|null}
39
+ */
40
+ function findMusterAutoload(target) {
41
+ const candidates = [
42
+ process.env.SHAKEDOWN_MUSTER_PATH && join(process.env.SHAKEDOWN_MUSTER_PATH, 'vendor/autoload.php'),
43
+ process.env.SHAKEDOWN_MUSTER_PATH,
44
+ target.sandbox?.musterPath && join(target.sandbox.musterPath, 'vendor/autoload.php'),
45
+ target.sandbox?.musterPath,
46
+ join(workspace, 'vendor/pressgang-wp/muster/vendor/autoload.php'),
47
+ existsSync(join(workspace, 'vendor/pressgang-wp/muster')) ? join(workspace, 'vendor/autoload.php') : null,
48
+ join(pkgRoot, '../pressgang-muster/vendor/autoload.php'),
49
+ ].filter(Boolean);
50
+
51
+ return candidates.find((p) => existsSync(p)) ?? null;
52
+ }
53
+
54
+ /**
55
+ * Pre-flight: surface `wp capstan doctor` config health before testing.
56
+ * A theme with FAILing checks isn't worth driving a browser at — abort with
57
+ * the failing checks named. Silent no-op when Capstan isn't installed.
58
+ */
59
+ function preflight(sitePath) {
60
+ const health = capstanDoctor(sitePath);
61
+ if (!health) return;
62
+
63
+ console.log(`⚓ capstan doctor: ${health.checks.length} checks, ${health.failures} failures, ${health.warnings} warnings`);
64
+
65
+ for (const check of health.checks.filter((c) => c.status !== 'OK')) {
66
+ console.log(` [${check.status}] ${check.check}: ${check.detail}`);
67
+ }
68
+
69
+ if (health.failures > 0) {
70
+ throw new Error('capstan doctor reports configuration failures — fix the theme before running trials.');
71
+ }
72
+ }
73
+
74
+ /** Derive and persist the matrix, printing a summary. */
75
+ function matrix(target) {
76
+ const { matrix: m, source } = deriveMatrix(target, workspace);
77
+ console.log(`⚓ ${m.routes.length} routes for ${target.baseUrl} (via ${source})`);
78
+
79
+ return m;
80
+ }
81
+
82
+ /** Run Playwright with the packaged config against the workspace. */
83
+ function test(extraArgs = []) {
84
+ // Resolve Playwright's CLI from this package's own dependency tree —
85
+ // consumers installing via file:/github: symlinks have no `playwright`
86
+ // binary on their PATH or in their local node_modules/.bin.
87
+ const playwrightCli = createRequire(import.meta.url).resolve('@playwright/test/cli');
88
+
89
+ const result = spawnSync(
90
+ process.execPath,
91
+ [playwrightCli, 'test', `--config=${join(pkgRoot, 'playwright.config.mjs')}`, ...extraArgs],
92
+ {
93
+ cwd: workspace,
94
+ stdio: 'inherit',
95
+ env: { ...process.env, SHAKEDOWN_WORKSPACE: workspace },
96
+ }
97
+ );
98
+
99
+ process.exitCode = result.status ?? 1;
100
+ }
101
+
102
+ try {
103
+ const target = resolveTarget(workspace, { target: targetFlag }, { requireBaseUrl: command !== 'sandbox' });
104
+
105
+ switch (command) {
106
+ case 'matrix':
107
+ for (const r of matrix(target).routes) {
108
+ console.log(` [${r.expect}] ${r.kind.padEnd(28)} ${r.url}`);
109
+ }
110
+ break;
111
+ case 'test':
112
+ test(args.slice(1));
113
+ break;
114
+ case 'ui':
115
+ test(['--ui']);
116
+ break;
117
+ case 'all':
118
+ preflight(target.sitePath);
119
+ matrix(target);
120
+ test(args.slice(1));
121
+ break;
122
+ case 'sandbox': {
123
+ // Throwaway WP: your code, symlinked read-only; its own SQLite DB and
124
+ // uploads in a temp dir. The real database is never touched — this is
125
+ // the only engine allowed to seed, because nothing persists.
126
+ console.log('⚓ assembling sandbox (fresh SQLite, code symlinked read-only)…');
127
+ const sandbox = await bootSandbox(target);
128
+ console.log(`⚓ sandbox up at ${sandbox.baseUrl} (isolation verified)`);
129
+ try {
130
+ // ACF state fixtures — populated + minimal per field group. Seeding
131
+ // is sandbox-only by design: the isolation witness has already
132
+ // proven this database is throwaway.
133
+ let states = [];
134
+ const muster = findMusterAutoload(target);
135
+ if (muster) {
136
+ states = seedAcfStates(sandbox, muster, {
137
+ seed: target.sandbox?.seed ?? 42,
138
+ epoch: target.sandbox?.epoch ?? DEFAULT_FIXTURE_EPOCH,
139
+ });
140
+ console.log(`⚓ seeded ${states.length} ACF state fixtures via Muster`);
141
+ } else {
142
+ console.log('⚓ Muster not found — skipping ACF state fixtures (set sandbox.musterPath)');
143
+ }
144
+
145
+ preflight(sandbox.root);
146
+ matrix({ ...target, name: 'sandbox', sitePath: sandbox.root, baseUrl: sandbox.baseUrl });
147
+
148
+ if (states.length > 0) {
149
+ mergeRoutes(workspace, states);
150
+ }
151
+
152
+ test(args.slice(1));
153
+ } finally {
154
+ sandbox.stop();
155
+ console.log('⚓ sandbox destroyed');
156
+ }
157
+ break;
158
+ }
159
+ default:
160
+ console.error(`Unknown command "${command}". Usage: shakedown [matrix|test|ui|sandbox] [--target=<name>]`);
161
+ process.exitCode = 1;
162
+ }
163
+ } catch (err) {
164
+ console.error(`shakedown: ${err.message}`);
165
+ process.exitCode = 1;
166
+ }