mandrel-platform 1.4.0 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,451 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-playwright-browser-install.test.mjs — regression guard for the e2e
4
+ * tier's Playwright browser install (Story #396).
5
+ *
6
+ * The bug this pins: the install step was gated on
7
+ * `steps.playwright-cache.outputs.cache-hit != 'true'`. `actions/cache` sets
8
+ * `cache-hit: true` on an EXACT key match and says nothing about what the
9
+ * restored tree actually contains, so the gate treats "an entry exists under
10
+ * this key" as proof the binaries are present. A cache saved partially — or
11
+ * saved before a Playwright patch added a browser variant under the same
12
+ * version key — therefore skips the only step that would repair it, and every
13
+ * scenario dies in milliseconds at `browserType.launch`.
14
+ *
15
+ * It could not self-heal in either direction: the hit kept skipping the
16
+ * repair, and Actions cache entries are IMMUTABLE under a key (`actions/cache`
17
+ * skips its post-job save on an exact hit), so the bad entry was never
18
+ * overwritten. Hence the two halves of the fix this file guards:
19
+ *
20
+ * 1. The install runs unconditionally, so a bad restore costs a re-download
21
+ * rather than the run. This is not a new cost — the pre-fix hit path
22
+ * already ran `playwright install-deps`, so the same OS-dependency step
23
+ * ran on BOTH branches; collapsing them adds only a browser-manifest
24
+ * verify.
25
+ * 2. A caller-settable salt is folded into the cache key, so an operator can
26
+ * stop paying that repair on every run by moving to a fresh key — without
27
+ * hand-deleting caches through the GitHub API.
28
+ *
29
+ * Asserting the key by string-matching its spelling would pin the text rather
30
+ * than the contract. The property that actually matters is RELATIONAL: the
31
+ * default salt must leave the key byte-for-byte identical to the pre-fix one
32
+ * (or every consumer's warm cache is silently invalidated by the upgrade), and
33
+ * distinct salts must produce distinct keys (or the escape hatch does not
34
+ * escape). So this extracts the real key template and resolves it under
35
+ * several salt values, the same read-then-execute approach as
36
+ * check-toolchain-cache-default.test.mjs.
37
+ *
38
+ * Run: node --test scripts/check-playwright-browser-install.test.mjs
39
+ */
40
+
41
+ import assert from "node:assert/strict";
42
+ import { test } from "node:test";
43
+ import { spawnSync } from "node:child_process";
44
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
45
+ import { tmpdir } from "node:os";
46
+ import { join } from "node:path";
47
+
48
+ const QUALITY = ".github/workflows/pr-quality.yml";
49
+ const SALT_INPUT = "playwright-cache-salt";
50
+
51
+ /**
52
+ * The fixed literal the resolve step falls back to (Story #400).
53
+ *
54
+ * It must stay a CONSTANT. A host- or run-derived fallback (`$GITHUB_SHA`, a
55
+ * date) satisfies "the step no longer fails" while minting a new cache key on
56
+ * every run — permanently defeating the ~460 MiB cache the step exists to
57
+ * label, which is a worse outcome than the abort it replaced.
58
+ */
59
+ const SENTINEL = "unresolved";
60
+
61
+ /** The exact key template the tier carried before Story #396. */
62
+ const PRE_FIX_KEY = "playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}";
63
+ const PRE_FIX_RESTORE_KEY = "playwright-${{ runner.os }}-";
64
+
65
+ const text = readFileSync(QUALITY, "utf8");
66
+
67
+ /**
68
+ * The workflow with whole-line `#` comments removed.
69
+ *
70
+ * The guard is about what the workflow DOES, not what it says: the tier
71
+ * carries a comment naming the very expression this file forbids, so that a
72
+ * future reader is told not to reintroduce it. Scanning raw text would let
73
+ * that warning fail the check it exists to support. Only leading-`#` lines are
74
+ * dropped — never a mid-line `#`, which could sit inside a quoted value.
75
+ */
76
+ const code = text
77
+ .split("\n")
78
+ .filter((l) => !l.trimStart().startsWith("#"))
79
+ .join("\n");
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Extraction
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * The `steps:` block of the named job, sliced by indentation.
87
+ *
88
+ * Scans lines rather than building a `new RegExp` around the job name: a
89
+ * dynamically-constructed regex is a SAST finding (ReDoS surface) and buys
90
+ * nothing here, since the block boundary is just indentation.
91
+ */
92
+ function jobBlock(name, source = code) {
93
+ const lines = source.split("\n");
94
+ const start = lines.indexOf(` ${name}:`);
95
+ assert.notEqual(start, -1, `${QUALITY}: job \`${name}\` not found`);
96
+ const out = [];
97
+ for (let i = start + 1; i < lines.length; i++) {
98
+ if (lines[i].trim() === "") {
99
+ out.push(lines[i]);
100
+ continue;
101
+ }
102
+ // Dedent to the job-name level or beyond → the block ended.
103
+ if (lines[i].match(/^(\s*)/)[1].length <= 2) break;
104
+ out.push(lines[i]);
105
+ }
106
+ return out.join("\n");
107
+ }
108
+
109
+ /**
110
+ * The `- name: <step>` … block for one step of a job, sliced by indentation.
111
+ *
112
+ * `source` defaults to the comment-stripped text — right for asserting what the
113
+ * workflow DOES. Pass the raw `text` when the block is going to be EXECUTED, so
114
+ * the guard runs the same script the runner does rather than a stripped
115
+ * paraphrase of it.
116
+ */
117
+ function stepBlock(job, stepName, source = code) {
118
+ const lines = jobBlock(job, source).split("\n");
119
+ const start = lines.findIndex((l) => l.trim() === `- name: ${stepName}`);
120
+ assert.notEqual(start, -1, `${QUALITY}: step \`${stepName}\` not found in job \`${job}\``);
121
+ const indent = lines[start].match(/^(\s*)/)[1].length;
122
+ const out = [lines[start]];
123
+ for (let i = start + 1; i < lines.length; i++) {
124
+ if (lines[i].trim() === "") continue;
125
+ const width = lines[i].match(/^(\s*)/)[1].length;
126
+ // A sibling list item (or a dedent) at the same indent ends this step.
127
+ if (width <= indent) break;
128
+ out.push(lines[i]);
129
+ }
130
+ return out.join("\n");
131
+ }
132
+
133
+ /** The literal `default:` of the named workflow_call input. */
134
+ function inputDefault(name) {
135
+ const lines = code.split("\n");
136
+ const start = lines.indexOf(` ${name}:`);
137
+ assert.notEqual(start, -1, `${QUALITY}: workflow_call input \`${name}\` not found`);
138
+ for (let i = start + 1; i < lines.length; i++) {
139
+ if (lines[i].trim() === "") continue;
140
+ if (lines[i].match(/^(\s*)/)[1].length <= 6) break;
141
+ const d = lines[i].match(/^\s*default:\s*(.+)$/);
142
+ if (d) return d[1].trim();
143
+ }
144
+ return assert.fail(`${QUALITY}: input \`${name}\` has no default`);
145
+ }
146
+
147
+ /** The `key:` / `restore-keys:` templates of the cache step. */
148
+ function cacheKeys() {
149
+ const block = stepBlock("e2e", "Cache Playwright browsers");
150
+ const key = block.match(/^\s*key:\s*(.+)$/m);
151
+ assert.ok(key, `${QUALITY}: the cache step has no \`key:\``);
152
+ const restore = block.match(/^\s*restore-keys:\s*\|\s*\n\s*(.+)$/m);
153
+ assert.ok(restore, `${QUALITY}: the cache step has no \`restore-keys:\``);
154
+ return { key: key[1].trim(), restoreKey: restore[1].trim() };
155
+ }
156
+
157
+ /**
158
+ * Resolve a key template for one salt value, leaving every other `${{ … }}`
159
+ * placeholder untouched so the result is directly comparable to the pre-fix
160
+ * literal. Split/join rather than a constructed regex, for the SAST reason
161
+ * above.
162
+ */
163
+ function resolveSalt(template, salt) {
164
+ return template
165
+ .split(`\${{ inputs.${SALT_INPUT} }}`)
166
+ .join(salt)
167
+ .split(`\${{ inputs['${SALT_INPUT}'] }}`)
168
+ .join(salt);
169
+ }
170
+
171
+ /**
172
+ * The dedented body of a step's `run: |` block, taken from the RAW workflow
173
+ * text so the guard executes what the runner executes.
174
+ *
175
+ * This is only sound while the block holds no `${{ }}` expression — the runner
176
+ * substitutes those before bash ever sees them, and there is no substituting
177
+ * them here. A dedicated test below pins that precondition rather than leaving
178
+ * it as a silent assumption.
179
+ */
180
+ function runScript(job, stepName) {
181
+ const lines = stepBlock(job, stepName, text).split("\n");
182
+ const start = lines.findIndex((l) => l.trim() === "run: |");
183
+ assert.notEqual(start, -1, `${QUALITY}: step \`${stepName}\` has no \`run: |\` block`);
184
+ const body = lines.slice(start + 1);
185
+ assert.ok(body.length > 0, `${QUALITY}: step \`${stepName}\` has an empty \`run:\` block`);
186
+ const indent = body[0].match(/^(\s*)/)[1].length;
187
+ return body.map((l) => l.slice(indent)).join("\n");
188
+ }
189
+
190
+ /**
191
+ * Run a script under the runner's own shell invocation, in a throwaway cwd.
192
+ *
193
+ * GitHub executes `shell: bash` as `bash --noprofile --norc -eo pipefail
194
+ * {0}` — the `-e` is the whole reason the pre-fix step could kill the tier, so
195
+ * the guard reproduces the flags exactly. `cwd` is passed to `spawnSync` and
196
+ * `process.chdir` is never called: this file's later tests read
197
+ * `docs/reusable-workflows.md` by a RELATIVE path, and a leaked cwd would fail
198
+ * them for a reason that has nothing to do with the change under test.
199
+ */
200
+ function runInDir(script, cwd) {
201
+ const outPath = join(cwd, "github-output");
202
+ writeFileSync(outPath, "");
203
+ const scriptPath = join(cwd, "step.sh");
204
+ writeFileSync(scriptPath, script);
205
+ const res = spawnSync("bash", ["--noprofile", "--norc", "-eo", "pipefail", scriptPath], {
206
+ cwd,
207
+ env: { ...process.env, GITHUB_OUTPUT: outPath },
208
+ encoding: "utf8",
209
+ });
210
+ const outputs = new Map();
211
+ for (const line of readFileSync(outPath, "utf8").split("\n")) {
212
+ const eq = line.indexOf("=");
213
+ if (eq > 0) outputs.set(line.slice(0, eq), line.slice(eq + 1));
214
+ }
215
+ return { status: res.status, stderr: res.stderr, outputs };
216
+ }
217
+
218
+ /** A temp directory, removed however the callback exits. */
219
+ function withTempDir(fn) {
220
+ const dir = mkdtempSync(join(tmpdir(), "pw-version-"));
221
+ try {
222
+ return fn(dir);
223
+ } finally {
224
+ rmSync(dir, { recursive: true, force: true });
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Plant a resolvable `@playwright/test` under `dir`.
230
+ *
231
+ * The `exports` map matters: the real package restricts subpath access and
232
+ * lists `"./package.json"` explicitly. Without it here, a resolution strategy
233
+ * that is ILLEGAL against the real package would still pass this guard.
234
+ */
235
+ function plantPlaywright(dir, version) {
236
+ const pkgDir = join(dir, "node_modules", "@playwright", "test");
237
+ mkdirSync(pkgDir, { recursive: true });
238
+ writeFileSync(
239
+ join(pkgDir, "package.json"),
240
+ JSON.stringify({
241
+ name: "@playwright/test",
242
+ version,
243
+ exports: { "./package.json": "./package.json" },
244
+ }),
245
+ );
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // The contract
250
+ // ---------------------------------------------------------------------------
251
+
252
+ test("no step gates on the Playwright cache-hit output", () => {
253
+ // AC-1, the defect itself. `cache-hit` is true whenever an entry EXISTS
254
+ // under the key — it is not a statement about the entry's contents, so no
255
+ // step may treat it as one.
256
+ assert.doesNotMatch(
257
+ code,
258
+ /steps\.playwright-cache\.outputs\.cache-hit/,
259
+ "a cache-hit gate is back: a partial cache would again be fatal rather than repaired",
260
+ );
261
+ });
262
+
263
+ test("the browser install runs unconditionally with --with-deps", () => {
264
+ // AC-1. Unconditional is the whole fix — an `if:` of ANY shape here
265
+ // reintroduces a path where a bad restore is never repaired.
266
+ const block = stepBlock("e2e", "Install Playwright browsers");
267
+ assert.match(block, /run:\s*pnpm exec playwright install --with-deps/);
268
+ assert.doesNotMatch(
269
+ block,
270
+ /^\s*if:/m,
271
+ "the install step must carry no condition — see this file's header",
272
+ );
273
+ });
274
+
275
+ test("the cache-hit-only OS-dependency step is gone", () => {
276
+ // AC-1. Its only reason to exist was the hit branch; leaving it behind
277
+ // would run `install-deps` twice on every run.
278
+ assert.doesNotMatch(
279
+ jobBlock("e2e"),
280
+ /- name: Install browser OS dependencies/,
281
+ "the split OS-dependency step is redundant once the install is unconditional",
282
+ );
283
+ });
284
+
285
+ test(`the ${SALT_INPUT} input exists with a literal default`, () => {
286
+ // AC-2. A `workflow_call` default may not hold an expression: GitHub
287
+ // resolves defaults during interface validation, before any context exists,
288
+ // and check-workflow-portability.mjs Rule 2 rejects it outright.
289
+ const value = inputDefault(SALT_INPUT);
290
+ assert.doesNotMatch(value, /\$\{\{/, "a workflow_call default may not hold an expression");
291
+ assert.equal(value, "''");
292
+ });
293
+
294
+ test("the cache key interpolates the salt input", () => {
295
+ // AC-2. Declared-but-unread is the failure mode that makes the escape hatch
296
+ // silently inert.
297
+ const { key } = cacheKeys();
298
+ assert.match(key, /inputs\./, "the key does not read any input");
299
+ assert.notEqual(
300
+ resolveSalt(key, "probe"),
301
+ key,
302
+ `the key does not interpolate \`inputs.${SALT_INPUT}\``,
303
+ );
304
+ });
305
+
306
+ test("the default salt leaves the cache key byte-for-byte unchanged", () => {
307
+ // AC-3 — the compatibility contract. If this drifts, every consumer's warm
308
+ // ~460 MiB cache is silently orphaned the moment they adopt the release,
309
+ // which is a worse outage than the bug being fixed.
310
+ const { key, restoreKey } = cacheKeys();
311
+ assert.equal(resolveSalt(key, ""), PRE_FIX_KEY);
312
+ assert.equal(resolveSalt(restoreKey, ""), PRE_FIX_RESTORE_KEY);
313
+ });
314
+
315
+ test("distinct salts produce distinct cache keys", () => {
316
+ // AC-4 — the escape hatch actually escapes. A salt that collapses into the
317
+ // same key (interpolated into a comment, or into a segment the key does not
318
+ // use) would leave the operator back at deleting caches by hand.
319
+ const { key } = cacheKeys();
320
+ const base = resolveSalt(key, "");
321
+ const bumped = resolveSalt(key, "-v2");
322
+ const bumpedAgain = resolveSalt(key, "-v3");
323
+ assert.notEqual(bumped, base, "a non-empty salt must not resolve to the default key");
324
+ assert.notEqual(bumpedAgain, bumped, "two different salts must not collide");
325
+ });
326
+
327
+ test("the salt does not disturb the restore-keys prefix", () => {
328
+ // A deliberate asymmetry, not an oversight: the prefix fallback must keep
329
+ // matching older entries so a salt bump still gets a WARM start. The
330
+ // unconditional install then fills whatever the old entry was missing, and
331
+ // because a prefix (non-exact) restore leaves `cache-hit` false, the
332
+ // post-job save writes a complete tree under the NEW key. That is what
333
+ // completes the escape — one run, no manual cache deletion.
334
+ const { restoreKey } = cacheKeys();
335
+ assert.equal(
336
+ resolveSalt(restoreKey, "-v2"),
337
+ PRE_FIX_RESTORE_KEY,
338
+ "restore-keys must stay salt-free so a bumped key still warm-starts",
339
+ );
340
+ });
341
+
342
+ test("the documented input row states the default and the escape semantics", () => {
343
+ // AC-6. The row is the consumer-facing contract for a knob whose entire
344
+ // purpose is manual operator use — undocumented, it may as well not exist.
345
+ const docs = readFileSync("docs/reusable-workflows.md", "utf8");
346
+ const rows = docs
347
+ .split("\n")
348
+ .filter((l) => l.startsWith(`| \`${SALT_INPUT}\``) && /\|\s*string\s*\|/.test(l));
349
+ assert.equal(rows.length, 1, "expected exactly one documented input row");
350
+ assert.match(rows[0], /`''`/, "row does not state the empty-string default");
351
+ assert.match(rows[0], /cache/i, "row does not explain what the salt affects");
352
+ });
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Version resolution (Story #400)
356
+ //
357
+ // The pre-fix step ran `node -e "…require('./node_modules/@playwright/test/…')"`
358
+ // as a bare `VAR=$(…)` assignment. Under `bash -eo pipefail` that propagates
359
+ // the substitution's exit status, so on a consumer whose ROOT node_modules
360
+ // lacks the package — pnpm's isolated layout only symlinks a root DIRECT
361
+ // dependency, so a workspace-owned Playwright has no such path — `set -e`
362
+ // killed the step and took the whole e2e tier with it. A step that exists only
363
+ // to LABEL a cache key must never be able to do that.
364
+ // ---------------------------------------------------------------------------
365
+
366
+ test("version resolution uses a bare specifier, not a hardcoded root path", () => {
367
+ const block = stepBlock("e2e", "Resolve Playwright version");
368
+ assert.doesNotMatch(
369
+ block,
370
+ /\.\/node_modules\/@playwright\/test/,
371
+ "a hardcoded root path is back: a workspace-owned Playwright would not resolve",
372
+ );
373
+ assert.match(
374
+ block,
375
+ /require\((['"])@playwright\/test\/package\.json\1\)/,
376
+ "resolution must go through the bare specifier, which walks node_modules",
377
+ );
378
+ });
379
+
380
+ test("the resolve step's run block holds no workflow expression", () => {
381
+ // The precondition for executing this step in the tests below: the runner
382
+ // substitutes `${{ }}` before bash sees it, and nothing substitutes it here.
383
+ // Threading an input into the block would leave the guard asserting against
384
+ // a string that never runs.
385
+ assert.doesNotMatch(
386
+ runScript("e2e", "Resolve Playwright version"),
387
+ /\$\{\{/,
388
+ "keep the run block expression-free so the guard executes the runner's text",
389
+ );
390
+ });
391
+
392
+ test("the sentinel is assigned as a fixed literal", () => {
393
+ // Invariant 2 (see SENTINEL above). Assert the SHAPE of the assignment, not
394
+ // merely that the step stopped failing — `PW_VERSION=$(date +%F)` would pass
395
+ // a stability check across two runs in the same second and still rekey the
396
+ // cache on every push.
397
+ const script = runScript("e2e", "Resolve Playwright version");
398
+ const assignments = script
399
+ .split("\n")
400
+ .map((l) => l.trim())
401
+ .filter((l) => !l.startsWith("#") && l.includes(`=${SENTINEL}`));
402
+ assert.equal(assignments.length, 1, `expected exactly one \`=${SENTINEL}\` assignment`);
403
+ const [assignment] = assignments;
404
+ assert.match(assignment, /^[A-Za-z_][A-Za-z0-9_]*=unresolved$/, "the sentinel must be a literal");
405
+ assert.ok(!assignment.includes("$("), "the sentinel must not be command-substituted");
406
+ assert.ok(!assignment.includes("${"), "the sentinel must not be parameter-expanded");
407
+ });
408
+
409
+ test("an unresolvable @playwright/test yields the sentinel instead of failing the tier", () => {
410
+ // The defect itself. A temp dir has no `node_modules` anywhere up its tree,
411
+ // which is exactly the consumer shape that lost the tier.
412
+ const script = runScript("e2e", "Resolve Playwright version");
413
+ withTempDir((dir) => {
414
+ const { status, outputs, stderr } = runInDir(script, dir);
415
+ assert.equal(status, 0, `the step must not fail the tier; stderr:\n${stderr}`);
416
+ assert.equal(outputs.get("version"), SENTINEL);
417
+ });
418
+ });
419
+
420
+ test("the sentinel is stable across runs, so the cache key does not churn", () => {
421
+ // Invariant 2 again, from the outside: a value that varies run to run mints a
422
+ // fresh key every time and permanently defeats the cache.
423
+ const script = runScript("e2e", "Resolve Playwright version");
424
+ const read = () => withTempDir((dir) => runInDir(script, dir).outputs.get("version"));
425
+ const first = read();
426
+ // Pin the value, not just its stability: two runs that both emit NOTHING are
427
+ // trivially equal, which would let a step that never writes the output pass.
428
+ assert.equal(first, SENTINEL);
429
+ assert.equal(read(), first);
430
+ });
431
+
432
+ test("a resolvable @playwright/test produces the pre-fix cache key exactly", () => {
433
+ // The compatibility contract, end to end: what the step actually emits, fed
434
+ // through the real key template at the default salt, must equal the key
435
+ // consumers' warm caches already sit under.
436
+ const script = runScript("e2e", "Resolve Playwright version");
437
+ const version = "1.61.1";
438
+ const resolved = withTempDir((dir) => {
439
+ plantPlaywright(dir, version);
440
+ const { status, outputs, stderr } = runInDir(script, dir);
441
+ assert.equal(status, 0, `stderr:\n${stderr}`);
442
+ return outputs.get("version");
443
+ });
444
+ assert.equal(resolved, version, "the step must report the resolved package's version");
445
+
446
+ const { key } = cacheKeys();
447
+ const withVersion = resolveSalt(key, "")
448
+ .split("${{ steps.pw-version.outputs.version }}")
449
+ .join(resolved);
450
+ assert.equal(withVersion, `playwright-\${{ runner.os }}-${version}`);
451
+ });