@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/lib/derive.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matrix derivation: enumerate the target site's route surface.
|
|
3
|
+
*
|
|
4
|
+
* Prefers `wp capstan matrix --resolve` (PressGang's own introspection —
|
|
5
|
+
* includes the expected-template/controller oracle for dispatched routes);
|
|
6
|
+
* falls back to the bundled matrix.php when Capstan isn't installed, which
|
|
7
|
+
* derives the same route families without oracle data.
|
|
8
|
+
*/
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse JSON out of WP-CLI output, tolerating pre-JSON noise
|
|
18
|
+
* (PHP notices on WP_DEBUG sites, plugin chatter).
|
|
19
|
+
*
|
|
20
|
+
* @param {string} out
|
|
21
|
+
* @returns {object|null}
|
|
22
|
+
*/
|
|
23
|
+
function parseJson(out) {
|
|
24
|
+
const start = out.indexOf('{');
|
|
25
|
+
if (start === -1) return null;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(out.slice(start));
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {ReturnType<import('./target.mjs').resolveTarget>} target
|
|
35
|
+
* @returns {{matrix: object, source: string}|null}
|
|
36
|
+
*/
|
|
37
|
+
function tryCapstan(target) {
|
|
38
|
+
try {
|
|
39
|
+
const out = execFileSync(
|
|
40
|
+
'wp',
|
|
41
|
+
[
|
|
42
|
+
'capstan',
|
|
43
|
+
'matrix',
|
|
44
|
+
'--resolve',
|
|
45
|
+
'--format=json',
|
|
46
|
+
`--samples=${target.samplesPerType}`,
|
|
47
|
+
`--search=${target.searchTerm}`,
|
|
48
|
+
`--path=${target.sitePath}`,
|
|
49
|
+
],
|
|
50
|
+
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
51
|
+
);
|
|
52
|
+
const matrix = parseJson(out);
|
|
53
|
+
|
|
54
|
+
return matrix ? { matrix, source: 'capstan' } : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {ReturnType<import('./target.mjs').resolveTarget>} target
|
|
62
|
+
* @returns {{matrix: object, source: string}}
|
|
63
|
+
*/
|
|
64
|
+
function runBundledScript(target) {
|
|
65
|
+
const out = execFileSync(
|
|
66
|
+
'wp',
|
|
67
|
+
[
|
|
68
|
+
'eval-file',
|
|
69
|
+
join(pkgRoot, 'bin/matrix.php'),
|
|
70
|
+
String(target.samplesPerType),
|
|
71
|
+
target.searchTerm,
|
|
72
|
+
`--path=${target.sitePath}`,
|
|
73
|
+
],
|
|
74
|
+
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
|
|
75
|
+
);
|
|
76
|
+
const matrix = parseJson(out);
|
|
77
|
+
|
|
78
|
+
if (!matrix) {
|
|
79
|
+
throw new Error(`matrix.php produced no JSON:\n${out.trim().slice(0, 500)}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { matrix, source: 'bundled matrix.php' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Derive the matrix and persist it to <workspace>/.shakedown/matrix.json.
|
|
87
|
+
*
|
|
88
|
+
* @param {ReturnType<import('./target.mjs').resolveTarget>} target
|
|
89
|
+
* @param {string} workspace Directory run artifacts belong to.
|
|
90
|
+
* @returns {{matrix: object, source: string, path: string}}
|
|
91
|
+
*/
|
|
92
|
+
/**
|
|
93
|
+
* Pre-flight configuration health via `wp capstan doctor --format=json`.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} sitePath
|
|
96
|
+
* @returns {{checks: array, failures: number, warnings: number}|null}
|
|
97
|
+
* null when Capstan isn't installed — the pre-flight is optional.
|
|
98
|
+
*/
|
|
99
|
+
export function capstanDoctor(sitePath) {
|
|
100
|
+
try {
|
|
101
|
+
const out = execFileSync('wp', ['capstan', 'doctor', '--format=json', `--path=${sitePath}`], {
|
|
102
|
+
encoding: 'utf8',
|
|
103
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return parseJson(out);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// Doctor exits non-zero when checks FAIL — that's a report, not an error.
|
|
109
|
+
const report = parseJson(String(err.stdout ?? ''));
|
|
110
|
+
|
|
111
|
+
return report ?? null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Merge extra routes into the persisted matrix, first-occurrence-wins on URL
|
|
117
|
+
* collisions — so specific labels (state:*) prepended here beat the generic
|
|
118
|
+
* single:* labels the derivation gave the same URLs.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} workspace
|
|
121
|
+
* @param {array<{url: string, kind: string, expect: number}>} extraRoutes
|
|
122
|
+
* @returns {object} The merged matrix.
|
|
123
|
+
*/
|
|
124
|
+
export function mergeRoutes(workspace, extraRoutes) {
|
|
125
|
+
const path = join(workspace, '.shakedown', 'matrix.json');
|
|
126
|
+
const matrix = JSON.parse(readFileSync(path, 'utf8'));
|
|
127
|
+
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
matrix.routes = [...extraRoutes, ...matrix.routes].filter((route) => {
|
|
130
|
+
if (seen.has(route.url)) return false;
|
|
131
|
+
seen.add(route.url);
|
|
132
|
+
return true;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
writeFileSync(path, JSON.stringify(matrix, null, 2));
|
|
136
|
+
|
|
137
|
+
return matrix;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function deriveMatrix(target, workspace) {
|
|
141
|
+
const { matrix, source } = tryCapstan(target) ?? runBundledScript(target);
|
|
142
|
+
|
|
143
|
+
matrix.target = target.name;
|
|
144
|
+
matrix.baseUrl = target.baseUrl;
|
|
145
|
+
|
|
146
|
+
const dir = join(workspace, '.shakedown');
|
|
147
|
+
mkdirSync(dir, { recursive: true });
|
|
148
|
+
const path = join(dir, 'matrix.json');
|
|
149
|
+
writeFileSync(path, JSON.stringify(matrix, null, 2));
|
|
150
|
+
|
|
151
|
+
return { matrix, source, path };
|
|
152
|
+
}
|
package/lib/sandbox.mjs
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox engine: a throwaway WordPress with an isolated SQLite database —
|
|
3
|
+
* WP's answer to Laravel's in-memory RefreshDatabase testing.
|
|
4
|
+
*
|
|
5
|
+
* Assembly (all in a temp dir, deleted after the run):
|
|
6
|
+
* - WordPress core, themes, plugins and mu-plugins are SYMLINKED read-only
|
|
7
|
+
* from the real project — the sandbox runs your actual code;
|
|
8
|
+
* - wp-config.php, the uploads dir, and the SQLite database are the
|
|
9
|
+
* sandbox's own — the real site's MySQL and uploads are never touched;
|
|
10
|
+
* - the SQLite Database Integration plugin (cached under ~/.cache) provides
|
|
11
|
+
* the db.php drop-in;
|
|
12
|
+
* - `wp core install` creates a fresh site, the project's child theme is
|
|
13
|
+
* activated, permalinks set, and `wp server` serves it on a local port.
|
|
14
|
+
*
|
|
15
|
+
* Nothing persists: fixtures, uploads, options — all evaporate with the
|
|
16
|
+
* temp dir. This is the only shakedown engine allowed to write to a
|
|
17
|
+
* database, because the database is disposable by construction.
|
|
18
|
+
*/
|
|
19
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
20
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { createServer } from 'node:net';
|
|
22
|
+
import { tmpdir, homedir } from 'node:os';
|
|
23
|
+
import { basename, dirname, join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const SQLITE_PLUGIN_ZIP = 'https://downloads.wordpress.org/plugin/sqlite-database-integration.latest-stable.zip';
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_FIXTURE_EPOCH = '2026-01-01T09:00:00+00:00';
|
|
29
|
+
|
|
30
|
+
const ABSOLUTE_EPOCH_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Download (once) and cache the SQLite Database Integration plugin.
|
|
34
|
+
*
|
|
35
|
+
* @returns {string} Path to the cached plugin directory.
|
|
36
|
+
*/
|
|
37
|
+
function ensureSqlitePlugin() {
|
|
38
|
+
const cache = join(homedir(), '.cache', 'pressgang-shakedown');
|
|
39
|
+
const pluginDir = join(cache, 'sqlite-database-integration');
|
|
40
|
+
|
|
41
|
+
if (!existsSync(pluginDir)) {
|
|
42
|
+
mkdirSync(cache, { recursive: true });
|
|
43
|
+
const zip = join(cache, 'sqlite.zip');
|
|
44
|
+
execFileSync('curl', ['-sL', '-o', zip, SQLITE_PLUGIN_ZIP]);
|
|
45
|
+
execFileSync('unzip', ['-oq', zip, '-d', cache]);
|
|
46
|
+
rmSync(zip);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return pluginDir;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Symlink an entry from the real project into the sandbox.
|
|
54
|
+
*/
|
|
55
|
+
function link(from, to) {
|
|
56
|
+
if (existsSync(from) && !existsSync(to)) {
|
|
57
|
+
symlinkSync(from, to);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Assemble the sandbox filesystem: linked code, owned state.
|
|
63
|
+
*
|
|
64
|
+
* Plugins are strictly allowlisted. The sandbox's job is testing the theme's
|
|
65
|
+
* rendering, not the site's plugin stack — security/caching/SEO plugins add
|
|
66
|
+
* nondeterministic noise (we caught one force-HTTPS-redirecting while
|
|
67
|
+
* nominally inactive). What isn't allowlisted isn't even symlinked in.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} sitePath Real project's WordPress root (contains wp-config.php).
|
|
70
|
+
* @param {array<string>} pluginAllowlist Plugin directory names to make available.
|
|
71
|
+
* @returns {string} Sandbox root path.
|
|
72
|
+
*/
|
|
73
|
+
function assemble(sitePath, pluginAllowlist = [], pathMap = {}) {
|
|
74
|
+
const tmp = mkdtempSync(join(tmpdir(), 'shakedown-sandbox-'));
|
|
75
|
+
|
|
76
|
+
// Subdirectory installs (core in /wp under a parent docroot, as Herd/Valet
|
|
77
|
+
// "wp in subfolder" setups use): mirror the real layout so root-relative
|
|
78
|
+
// asset URLs (/assets/…) resolve. The parent's entries are linked in and
|
|
79
|
+
// the core assembly lands under the same subdirectory name.
|
|
80
|
+
const parent = dirname(sitePath);
|
|
81
|
+
const parentIndex = join(parent, 'index.php');
|
|
82
|
+
const subdir = existsSync(parentIndex) && readFileSync(parentIndex, 'utf8').includes('wp-blog-header')
|
|
83
|
+
? basename(sitePath)
|
|
84
|
+
: null;
|
|
85
|
+
|
|
86
|
+
let docroot = tmp;
|
|
87
|
+
let root = tmp;
|
|
88
|
+
|
|
89
|
+
if (subdir) {
|
|
90
|
+
for (const entry of readdirSync(parent)) {
|
|
91
|
+
if (entry === basename(sitePath) || entry.startsWith('.')) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const from = join(parent, entry);
|
|
95
|
+
if (entry.endsWith('.php')) {
|
|
96
|
+
cpSync(from, join(tmp, entry));
|
|
97
|
+
} else {
|
|
98
|
+
link(from, join(tmp, entry));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
root = join(tmp, subdir);
|
|
103
|
+
mkdirSync(root);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Site-specific docroot mappings the real web server provides via rewrites
|
|
107
|
+
// (e.g. BHP serves /assets from patterns/public/assets). Configured per
|
|
108
|
+
// target: sandbox.map = { "assets": "patterns/public/assets" }, paths
|
|
109
|
+
// relative to the real docroot.
|
|
110
|
+
const realDocroot = subdir ? parent : sitePath;
|
|
111
|
+
for (const [urlPath, realRelative] of Object.entries(pathMap)) {
|
|
112
|
+
link(join(realDocroot, realRelative), join(docroot, urlPath));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Core: directories are linked read-only, but root-level PHP entry files
|
|
116
|
+
// (index.php, wp-load.php, wp-settings.php…) are COPIED. PHP resolves
|
|
117
|
+
// __DIR__ through symlinks to the real path, so a symlinked wp-load.php
|
|
118
|
+
// sets ABSPATH to the real install and loads the REAL wp-config.php —
|
|
119
|
+
// real database, real plugin stack. Copying keeps ABSPATH inside the
|
|
120
|
+
// sandbox; this is the isolation boundary, not an optimisation.
|
|
121
|
+
for (const entry of readdirSync(sitePath)) {
|
|
122
|
+
if (entry === 'wp-config.php' || entry === 'wp-content' || entry.startsWith('.')) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const from = join(sitePath, entry);
|
|
127
|
+
|
|
128
|
+
if (entry.endsWith('.php')) {
|
|
129
|
+
cpSync(from, join(root, entry));
|
|
130
|
+
} else {
|
|
131
|
+
link(from, join(root, entry));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// wp-content: own directory; code linked, state owned.
|
|
136
|
+
const content = join(root, 'wp-content');
|
|
137
|
+
mkdirSync(join(content, 'uploads'), { recursive: true });
|
|
138
|
+
mkdirSync(join(root, 'database'));
|
|
139
|
+
|
|
140
|
+
for (const dir of ['themes', 'languages']) {
|
|
141
|
+
link(join(sitePath, 'wp-content', dir), join(content, dir));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// mu-plugins: a real directory with entries linked individually, so the
|
|
145
|
+
// sandbox can add its own helpers without touching the project's dir.
|
|
146
|
+
mkdirSync(join(content, 'mu-plugins'), { recursive: true });
|
|
147
|
+
const realMuPlugins = join(sitePath, 'wp-content', 'mu-plugins');
|
|
148
|
+
if (existsSync(realMuPlugins)) {
|
|
149
|
+
for (const entry of readdirSync(realMuPlugins)) {
|
|
150
|
+
if (!entry.startsWith('.')) {
|
|
151
|
+
link(join(realMuPlugins, entry), join(content, 'mu-plugins', entry));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Plugins: allowlist only.
|
|
157
|
+
mkdirSync(join(content, 'plugins'), { recursive: true });
|
|
158
|
+
const realPlugins = join(sitePath, 'wp-content', 'plugins');
|
|
159
|
+
for (const plugin of pluginAllowlist) {
|
|
160
|
+
const from = join(realPlugins, plugin);
|
|
161
|
+
if (existsSync(from)) {
|
|
162
|
+
link(from, join(content, 'plugins', plugin));
|
|
163
|
+
} else {
|
|
164
|
+
console.warn(`⚠ sandbox: allowlisted plugin "${plugin}" not found in ${realPlugins}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Observer: exposes template/controller/PHP-issue headers per request for
|
|
169
|
+
// the oracle assertions in pass 00. Sandbox-only, like all mu additions.
|
|
170
|
+
cpSync(
|
|
171
|
+
join(dirname(dirname(fileURLToPath(import.meta.url))), 'php', 'observer.php'),
|
|
172
|
+
join(content, 'mu-plugins', 'ab-shakedown-observer.php')
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Isolation witness: answers /?shakedown_whoami=1 with the paths this
|
|
176
|
+
// WordPress is actually running from, so boot can PROVE the sandbox is
|
|
177
|
+
// decoupled before any test traffic flows.
|
|
178
|
+
writeFileSync(join(content, 'mu-plugins', 'aa-shakedown-sandbox.php'), `<?php
|
|
179
|
+
/**
|
|
180
|
+
* Shakedown sandbox witness (sandbox-only; never installed on real sites).
|
|
181
|
+
*/
|
|
182
|
+
if ( isset( $_GET['shakedown_whoami'] ) ) {
|
|
183
|
+
\theader( 'Content-Type: application/json' );
|
|
184
|
+
\techo json_encode( [
|
|
185
|
+
\t\t'abspath' => ABSPATH,
|
|
186
|
+
\t\t'content_dir' => WP_CONTENT_DIR,
|
|
187
|
+
\t\t'sqlite' => defined( 'DB_DIR' ) ? DB_DIR : null,
|
|
188
|
+
\t] );
|
|
189
|
+
\texit;
|
|
190
|
+
}
|
|
191
|
+
`);
|
|
192
|
+
|
|
193
|
+
// SQLite integration: plugin dir + the db.php drop-in built from its template.
|
|
194
|
+
const sqlite = ensureSqlitePlugin();
|
|
195
|
+
cpSync(sqlite, join(content, 'plugins', 'sqlite-database-integration'), { recursive: true });
|
|
196
|
+
|
|
197
|
+
const dropIn = readFileSync(join(sqlite, 'db.copy'), 'utf8')
|
|
198
|
+
.replaceAll('{SQLITE_IMPLEMENTATION_FOLDER_PATH}', join(content, 'plugins', 'sqlite-database-integration'))
|
|
199
|
+
.replaceAll('{SQLITE_MAIN_FILE}', join(content, 'plugins', 'sqlite-database-integration', 'load.php'));
|
|
200
|
+
writeFileSync(join(content, 'db.php'), dropIn);
|
|
201
|
+
|
|
202
|
+
return { docroot, root, subdir };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Write the sandbox's own wp-config.php.
|
|
207
|
+
*/
|
|
208
|
+
function writeConfig(root, url, subdir = null) {
|
|
209
|
+
const siteUrl = subdir ? `${url}/${subdir}` : url;
|
|
210
|
+
writeFileSync(join(root, 'wp-config.php'), `<?php
|
|
211
|
+
// Shakedown sandbox — disposable. The real site's configuration is never read.
|
|
212
|
+
define( 'DB_NAME', 'shakedown' );
|
|
213
|
+
define( 'DB_USER', '' );
|
|
214
|
+
define( 'DB_PASSWORD', '' );
|
|
215
|
+
define( 'DB_HOST', '' );
|
|
216
|
+
define( 'DB_DIR', __DIR__ . '/database' );
|
|
217
|
+
define( 'DB_FILE', '.ht.sqlite' );
|
|
218
|
+
define( 'WP_CONTENT_DIR', __DIR__ . '/wp-content' );
|
|
219
|
+
define( 'WP_CONTENT_URL', '${siteUrl}/wp-content' );
|
|
220
|
+
define( 'WP_HOME', '${url}' );
|
|
221
|
+
define( 'WP_SITEURL', '${siteUrl}' );
|
|
222
|
+
define( 'WP_DEBUG', true );
|
|
223
|
+
define( 'WP_DEBUG_LOG', __DIR__ . '/debug.log' );
|
|
224
|
+
define( 'WP_DEBUG_DISPLAY', false );
|
|
225
|
+
define( 'WP_ENVIRONMENT_TYPE', 'local' );
|
|
226
|
+
define( 'DISABLE_WP_CRON', true );
|
|
227
|
+
define( 'AUTH_KEY', 'shakedown' );
|
|
228
|
+
define( 'SECURE_AUTH_KEY', 'shakedown' );
|
|
229
|
+
define( 'LOGGED_IN_KEY', 'shakedown' );
|
|
230
|
+
define( 'NONCE_KEY', 'shakedown' );
|
|
231
|
+
define( 'AUTH_SALT', 'shakedown' );
|
|
232
|
+
define( 'SECURE_AUTH_SALT', 'shakedown' );
|
|
233
|
+
define( 'LOGGED_IN_SALT', 'shakedown' );
|
|
234
|
+
define( 'NONCE_SALT', 'shakedown' );
|
|
235
|
+
$table_prefix = 'wp_';
|
|
236
|
+
if ( ! defined( 'ABSPATH' ) ) {
|
|
237
|
+
\tdefine( 'ABSPATH', __DIR__ . '/' );
|
|
238
|
+
}
|
|
239
|
+
require_once ABSPATH . 'wp-settings.php';
|
|
240
|
+
`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Run WP-CLI against the sandbox.
|
|
245
|
+
*/
|
|
246
|
+
function wp(root, args) {
|
|
247
|
+
return execFileSync('wp', [...args, `--path=${root}`], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Boot a sandbox for a target: assemble, install, activate, serve.
|
|
252
|
+
*
|
|
253
|
+
* @param {{sitePath: string}} target Resolved shakedown target (real project).
|
|
254
|
+
* @param {{port?: number, theme?: string}} options
|
|
255
|
+
* @returns {{baseUrl: string, root: string, stop: () => void}}
|
|
256
|
+
*/
|
|
257
|
+
export async function bootSandbox(target, options = {}) {
|
|
258
|
+
// An OS-assigned ephemeral port: fixed defaults collide with stale
|
|
259
|
+
// servers from crashed runs, which then answer for a deleted sandbox.
|
|
260
|
+
const port = options.port ?? (await freePort());
|
|
261
|
+
const url = `http://127.0.0.1:${port}`;
|
|
262
|
+
|
|
263
|
+
const allowlist = options.plugins ?? target.sandbox?.plugins ?? [];
|
|
264
|
+
const { docroot, root, subdir } = assemble(target.sitePath, allowlist, target.sandbox?.map ?? {});
|
|
265
|
+
writeConfig(root, url, subdir);
|
|
266
|
+
|
|
267
|
+
wp(root, [
|
|
268
|
+
'core', 'install',
|
|
269
|
+
`--url=${url}`,
|
|
270
|
+
'--title=Shakedown Sandbox',
|
|
271
|
+
'--admin_user=shakedown',
|
|
272
|
+
'--admin_password=shakedown',
|
|
273
|
+
'--admin_email=sandbox@shakedown.test',
|
|
274
|
+
'--skip-email',
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
const theme = options.theme ?? detectChildTheme(target.sitePath);
|
|
278
|
+
if (theme) {
|
|
279
|
+
wp(root, ['theme', 'activate', theme]);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const plugin of allowlist) {
|
|
283
|
+
if (existsSync(join(root, 'wp-content', 'plugins', plugin))) {
|
|
284
|
+
wp(root, ['plugin', 'activate', plugin]);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
wp(root, ['rewrite', 'structure', '/%postname%/', '--hard']);
|
|
289
|
+
|
|
290
|
+
const server = spawn('wp', ['server', `--host=127.0.0.1`, `--port=${port}`, `--docroot=${docroot}`, `--path=${root}`], {
|
|
291
|
+
stdio: 'ignore',
|
|
292
|
+
detached: false,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
waitForServer(url);
|
|
296
|
+
assertIsolated(url, docroot, server);
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
baseUrl: url,
|
|
300
|
+
root,
|
|
301
|
+
theme,
|
|
302
|
+
stop() {
|
|
303
|
+
server.kill();
|
|
304
|
+
rmSync(docroot, { recursive: true, force: true });
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Seed ACF state fixtures (populated + minimal per field group) inside a
|
|
311
|
+
* booted sandbox, via Muster. Only ever called for sandboxes — the isolation
|
|
312
|
+
* witness has already proven the database is throwaway before seeding runs.
|
|
313
|
+
*
|
|
314
|
+
* @param {{root: string, theme: string|null}} sandbox
|
|
315
|
+
* @param {string} musterAutoload Path to Muster's vendor/autoload.php.
|
|
316
|
+
* @param {{seed?: number, epoch?: string}} options Deterministic random and time inputs.
|
|
317
|
+
* @returns {array<{url: string, kind: string, expect: number}>} State routes.
|
|
318
|
+
*/
|
|
319
|
+
export function seedAcfStates(
|
|
320
|
+
sandbox,
|
|
321
|
+
musterAutoload,
|
|
322
|
+
{ seed = 42, epoch = DEFAULT_FIXTURE_EPOCH } = {}
|
|
323
|
+
) {
|
|
324
|
+
if (!Number.isInteger(seed)) {
|
|
325
|
+
throw new TypeError('Muster fixture seed must be an integer.');
|
|
326
|
+
}
|
|
327
|
+
if (
|
|
328
|
+
typeof epoch !== 'string' ||
|
|
329
|
+
!ABSOLUTE_EPOCH_PATTERN.test(epoch) ||
|
|
330
|
+
Number.isNaN(Date.parse(epoch))
|
|
331
|
+
) {
|
|
332
|
+
throw new TypeError('Muster fixture epoch must be a timezone-qualified ISO 8601 datetime.');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const acfJsonDir = join(sandbox.root, 'wp-content', 'themes', sandbox.theme ?? '', 'acf-json');
|
|
336
|
+
|
|
337
|
+
if (!sandbox.theme || !existsSync(acfJsonDir)) {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const script = join(dirname(dirname(new URL(import.meta.url).pathname)), 'bin', 'seed-states.php');
|
|
342
|
+
const out = execFileSync(
|
|
343
|
+
'wp',
|
|
344
|
+
['eval-file', script, musterAutoload, acfJsonDir, String(seed), epoch, `--path=${sandbox.root}`],
|
|
345
|
+
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const start = out.indexOf('{');
|
|
349
|
+
if (start === -1) {
|
|
350
|
+
throw new Error(`seed-states produced no JSON:\n${out.trim().slice(0, 500)}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return JSON.parse(out.slice(start)).routes;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Prove the served WordPress runs from the sandbox before any test traffic:
|
|
358
|
+
* ABSPATH and WP_CONTENT_DIR must point into the temp dir and the database
|
|
359
|
+
* must be the sandbox SQLite. If not, kill everything — a sandbox that can
|
|
360
|
+
* reach the real site's config/database must never be tested against.
|
|
361
|
+
*
|
|
362
|
+
* @param {string} url
|
|
363
|
+
* @param {string} root
|
|
364
|
+
* @param {import('node:child_process').ChildProcess} server
|
|
365
|
+
*/
|
|
366
|
+
function assertIsolated(url, root, server) {
|
|
367
|
+
let who = {};
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
who = JSON.parse(execFileSync('curl', ['-s', `${url}/?shakedown_whoami=1`], { encoding: 'utf8' }));
|
|
371
|
+
} catch {
|
|
372
|
+
// fall through to the failure below
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// PHP reports realpaths; on macOS the temp dir arrives via the /var →
|
|
376
|
+
// /private/var symlink, so compare against the resolved root.
|
|
377
|
+
const real = realpathSync(root);
|
|
378
|
+
const inside = (p) => typeof p === 'string' && (p.startsWith(root) || p.startsWith(real));
|
|
379
|
+
|
|
380
|
+
if (!inside(who.content_dir) || !inside(who.sqlite)) {
|
|
381
|
+
server.kill();
|
|
382
|
+
rmSync(root, { recursive: true, force: true });
|
|
383
|
+
throw new Error(
|
|
384
|
+
`sandbox isolation check FAILED (got ${JSON.stringify(who)}) — refusing to run tests against a non-isolated WordPress.`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Ask the OS for a free ephemeral port.
|
|
391
|
+
*
|
|
392
|
+
* @returns {Promise<number>}
|
|
393
|
+
*/
|
|
394
|
+
function freePort() {
|
|
395
|
+
return new Promise((resolve, reject) => {
|
|
396
|
+
const server = createServer();
|
|
397
|
+
server.listen(0, '127.0.0.1', () => {
|
|
398
|
+
const { port } = server.address();
|
|
399
|
+
server.close(() => resolve(port));
|
|
400
|
+
});
|
|
401
|
+
server.on('error', reject);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The project's child theme: the theme directory whose style.css declares a
|
|
407
|
+
* Template (parent), preferring one matching a known PressGang parent.
|
|
408
|
+
*
|
|
409
|
+
* @param {string} sitePath
|
|
410
|
+
* @returns {string|null}
|
|
411
|
+
*/
|
|
412
|
+
function detectChildTheme(sitePath) {
|
|
413
|
+
const themes = join(sitePath, 'wp-content', 'themes');
|
|
414
|
+
if (!existsSync(themes)) return null;
|
|
415
|
+
|
|
416
|
+
for (const dir of readdirSync(themes)) {
|
|
417
|
+
const css = join(themes, dir, 'style.css');
|
|
418
|
+
if (existsSync(css) && /^[\s*]*Template:\s*pressgang/m.test(readFileSync(css, 'utf8'))) {
|
|
419
|
+
return dir;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Poll until the sandbox answers (or fail after ~15s).
|
|
428
|
+
*/
|
|
429
|
+
function waitForServer(url, attempts = 30) {
|
|
430
|
+
for (let i = 0; i < attempts; i++) {
|
|
431
|
+
try {
|
|
432
|
+
execFileSync('curl', ['-s', '-o', '/dev/null', '--max-time', '2', url]);
|
|
433
|
+
return;
|
|
434
|
+
} catch {
|
|
435
|
+
execFileSync('sleep', ['0.5']);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
throw new Error(`Sandbox did not answer at ${url}`);
|
|
440
|
+
}
|
package/lib/target.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target resolution: which site is under trial, and where is it?
|
|
3
|
+
*
|
|
4
|
+
* Resolution order:
|
|
5
|
+
* 1. `shakedown.config.json` found in the cwd or an ancestor directory.
|
|
6
|
+
* - With a `targets` map (central/multi-site shape): pick `--target`,
|
|
7
|
+
* $SHAKEDOWN_TARGET, or `defaultTarget`.
|
|
8
|
+
* - Otherwise the file is a single-target config for this theme.
|
|
9
|
+
* 2. Auto-detection fills any gaps: walk up from cwd to find wp-config.php
|
|
10
|
+
* (the WP-CLI `sitePath`), then ask WP-CLI for the home URL.
|
|
11
|
+
*
|
|
12
|
+
* A theme therefore needs NO config at all when its site is resolvable by
|
|
13
|
+
* WP-CLI and served at its home URL — config exists for overrides.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Find a file in `start` or the nearest ancestor directory.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} start
|
|
23
|
+
* @param {string} name
|
|
24
|
+
* @returns {string|null} Absolute path of the containing directory, or null.
|
|
25
|
+
*/
|
|
26
|
+
function findUp(start, name) {
|
|
27
|
+
let dir = start;
|
|
28
|
+
for (;;) {
|
|
29
|
+
if (existsSync(join(dir, name))) return dir;
|
|
30
|
+
const parent = dirname(dir);
|
|
31
|
+
if (parent === dir) return null;
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ask WP-CLI for the site's home URL.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} sitePath
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
function detectBaseUrl(sitePath) {
|
|
43
|
+
const out = execFileSync('wp', ['option', 'get', 'home', `--path=${sitePath}`, '--skip-plugins', '--skip-themes'], {
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
});
|
|
46
|
+
const lines = out.trim().split('\n');
|
|
47
|
+
|
|
48
|
+
return lines[lines.length - 1].trim().replace(/\/$/, '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the target for a shakedown run.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} cwd Directory the CLI was invoked from (the workspace).
|
|
55
|
+
* @param {{target?: string}} flags
|
|
56
|
+
* @param {{requireBaseUrl?: boolean}} options Sandbox runs serve their own
|
|
57
|
+
* URL, so they resolve targets without needing the real site to answer
|
|
58
|
+
* WP-CLI (a bare core checkout in CI has no installed database).
|
|
59
|
+
* @returns {{name: string, sitePath: string, baseUrl: string, samplesPerType: number, searchTerm: string}}
|
|
60
|
+
*/
|
|
61
|
+
export function resolveTarget(cwd, flags = {}, { requireBaseUrl = true } = {}) {
|
|
62
|
+
let config = {};
|
|
63
|
+
let name = 'auto';
|
|
64
|
+
|
|
65
|
+
const configDir = findUp(cwd, 'shakedown.config.json');
|
|
66
|
+
if (configDir) {
|
|
67
|
+
const file = JSON.parse(readFileSync(join(configDir, 'shakedown.config.json'), 'utf8'));
|
|
68
|
+
|
|
69
|
+
if (file.targets) {
|
|
70
|
+
name = flags.target || process.env.SHAKEDOWN_TARGET || file.defaultTarget;
|
|
71
|
+
config = file.targets[name];
|
|
72
|
+
if (!config) {
|
|
73
|
+
throw new Error(`Unknown target "${name}". Available: ${Object.keys(file.targets).join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
config = file;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sitePath = config.sitePath ?? findUp(cwd, 'wp-config.php');
|
|
81
|
+
if (!sitePath) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'No WordPress found: no sitePath in shakedown.config.json and no wp-config.php in any ancestor directory.'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const baseUrl = (config.baseUrl ?? (requireBaseUrl ? detectBaseUrl(sitePath) : 'http://sandbox.invalid')).replace(/\/$/, '');
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
name,
|
|
91
|
+
sitePath,
|
|
92
|
+
baseUrl,
|
|
93
|
+
samplesPerType: config.samplesPerType ?? 2,
|
|
94
|
+
searchTerm: config.searchTerm ?? 'test',
|
|
95
|
+
// Sandbox policy, e.g. { "plugins": ["contact-form-7"] } — the plugin
|
|
96
|
+
// allowlist for throwaway environments (default: none).
|
|
97
|
+
sandbox: config.sandbox ?? {},
|
|
98
|
+
};
|
|
99
|
+
}
|