@pressgang-wp/shakedown 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/bin/derive-matrix.mjs +18 -0
- package/bin/matrix.php +166 -0
- package/bin/seed-states.php +116 -0
- package/bin/shakedown.mjs +166 -0
- package/lib/derive.mjs +152 -0
- package/lib/sandbox.mjs +440 -0
- package/lib/target.mjs +99 -0
- package/lib/trial-reporter.mjs +163 -0
- package/package.json +37 -0
- package/php/observer.php +69 -0
- package/playwright.config.mjs +45 -0
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
|
+
}
|