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
|
@@ -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
|
+
});
|