mandrel-platform 1.3.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 +2 -2
- package/scripts/check-first-party-pin-freshness.mjs +74 -12
- package/scripts/check-first-party-pin-freshness.test.mjs +161 -0
- package/scripts/check-playwright-browser-install.test.mjs +451 -0
- package/scripts/osv-track-issue.test.mjs +155 -0
- package/scripts/track-issue.test.mjs +375 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel-platform",
|
|
3
|
-
"version": "1.
|
|
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": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"provenance": true
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"mandrel": "^2.
|
|
47
|
+
"mandrel": "^2.31.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"typecheck": "node --input-type=module --eval 'process.exit(0)'",
|
|
@@ -38,6 +38,22 @@
|
|
|
38
38
|
* <subpath>` names, plus every tracked working-tree file under it, so an added
|
|
39
39
|
* or removed sibling is drift too.
|
|
40
40
|
*
|
|
41
|
+
* ## Why a subpath can have COMPANIONS (Story #389)
|
|
42
|
+
*
|
|
43
|
+
* The subpaths this checker knows are exactly the ones named on `uses:` lines,
|
|
44
|
+
* which reopens the same blind spot one level up the moment an action's
|
|
45
|
+
* behaviour moves into a SIBLING DIRECTORY. Story #389 reduced
|
|
46
|
+
* `.github/actions/osv-track-issue` to a thin preset that executes
|
|
47
|
+
* `.github/actions/track-issue/track-issue.mjs` — nothing `uses:` the generic
|
|
48
|
+
* action, so a rewrite of that shared core would leave every `osv-track-issue`
|
|
49
|
+
* pin reading fresh while the pinned SHA runs the old state machine.
|
|
50
|
+
*
|
|
51
|
+
* `COMPANION_SUBPATHS` closes it: a subpath's companions are folded into the
|
|
52
|
+
* tree comparison and into the drift cache key, so an edit confined to the
|
|
53
|
+
* shared core marks every call site of every dependent preset stale. The map
|
|
54
|
+
* is deliberately explicit rather than inferred — a heuristic over `run:`
|
|
55
|
+
* bodies would both miss indirection and invent false drift.
|
|
56
|
+
*
|
|
41
57
|
* This checker closes it by classifying every first-party SHA pin into one of
|
|
42
58
|
* two failure classes — deliberately kept distinct, because their remedies
|
|
43
59
|
* differ:
|
|
@@ -325,6 +341,33 @@ export function createGit(repoRoot) {
|
|
|
325
341
|
// Subpath tree comparison
|
|
326
342
|
// ---------------------------------------------------------------------------
|
|
327
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Subpaths whose runtime behaviour lives partly in ANOTHER directory that no
|
|
346
|
+
* `uses:` line names. Each key's companions are compared alongside it, so an
|
|
347
|
+
* edit confined to a shared core still marks the dependent pins stale.
|
|
348
|
+
*
|
|
349
|
+
* Keep this map in step with any preset/core split under `.github/actions/`:
|
|
350
|
+
* an entry omitted here is a pin that reads fresh while running old code.
|
|
351
|
+
*
|
|
352
|
+
* @type {Record<string, string[]>}
|
|
353
|
+
*/
|
|
354
|
+
export const COMPANION_SUBPATHS = {
|
|
355
|
+
// Story #389 — the preset executes ../track-issue/track-issue.mjs.
|
|
356
|
+
".github/actions/osv-track-issue": [".github/actions/track-issue"],
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Companion subpaths for a `uses:` subpath (empty when it stands alone).
|
|
361
|
+
* Trailing slashes are tolerated so `foo/` and `foo` resolve identically.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} subpath
|
|
364
|
+
* @param {Record<string, string[]>} [map]
|
|
365
|
+
* @returns {string[]}
|
|
366
|
+
*/
|
|
367
|
+
export function companionsFor(subpath, map = COMPANION_SUBPATHS) {
|
|
368
|
+
return map[String(subpath).replace(/\/+$/, "")] || [];
|
|
369
|
+
}
|
|
370
|
+
|
|
328
371
|
/** How each drift kind reads in the report. */
|
|
329
372
|
const DRIFT_PHRASE = {
|
|
330
373
|
differs: "differs from the working-tree copy",
|
|
@@ -341,15 +384,21 @@ const DRIFT_PHRASE = {
|
|
|
341
384
|
* since the pinned revision is drift just as much as one whose bytes changed —
|
|
342
385
|
* all three change what the pinned revision actually executes.
|
|
343
386
|
*
|
|
387
|
+
* `companions` extends the comparison to directories the subpath EXECUTES but
|
|
388
|
+
* no `uses:` line names (Story #389). They are compared identically: a shared
|
|
389
|
+
* core that differs at the pinned SHA is exactly as inert as a sibling script.
|
|
390
|
+
*
|
|
344
391
|
* @param {{lsTree: Function, lsFiles: Function, show: Function}} git
|
|
345
392
|
* @param {string} repoRoot
|
|
346
393
|
* @param {string} sha
|
|
347
394
|
* @param {string} subpath
|
|
395
|
+
* @param {string[]} [companions]
|
|
348
396
|
* @returns {Array<{path: string, kind: "differs" | "added" | "removed" | "unreadable"}>}
|
|
349
397
|
*/
|
|
350
|
-
export function diffSubpathAtSha(git, repoRoot, sha, subpath) {
|
|
351
|
-
const
|
|
352
|
-
const
|
|
398
|
+
export function diffSubpathAtSha(git, repoRoot, sha, subpath, companions = []) {
|
|
399
|
+
const roots = [subpath, ...companions];
|
|
400
|
+
const pinned = new Set(roots.flatMap((root) => git.lsTree(sha, root)));
|
|
401
|
+
const working = new Set(roots.flatMap((root) => git.lsFiles(root)));
|
|
353
402
|
const drift = [];
|
|
354
403
|
|
|
355
404
|
for (const path of [...new Set([...pinned, ...working])].sort()) {
|
|
@@ -384,12 +433,17 @@ export function diffSubpathAtSha(git, repoRoot, sha, subpath) {
|
|
|
384
433
|
*
|
|
385
434
|
* @param {string} subpath
|
|
386
435
|
* @param {ReturnType<typeof diffSubpathAtSha>} drift
|
|
436
|
+
* @param {string[]} [companions]
|
|
387
437
|
* @returns {string}
|
|
388
438
|
*/
|
|
389
|
-
export function describeDrift(subpath, drift) {
|
|
439
|
+
export function describeDrift(subpath, drift, companions = []) {
|
|
390
440
|
const detail = drift.map((d) => `${d.path} ${DRIFT_PHRASE[d.kind]}`).join("; ");
|
|
441
|
+
const scope =
|
|
442
|
+
companions.length > 0
|
|
443
|
+
? `${subpath} (and the shared code it executes: ${companions.join(", ")})`
|
|
444
|
+
: subpath;
|
|
391
445
|
return (
|
|
392
|
-
`${drift.length} file(s) under ${
|
|
446
|
+
`${drift.length} file(s) under ${scope} lag the pinned SHA — the pinned ` +
|
|
393
447
|
`revision is what actually runs: ${detail}`
|
|
394
448
|
);
|
|
395
449
|
}
|
|
@@ -406,7 +460,11 @@ export function describeDrift(subpath, drift) {
|
|
|
406
460
|
* `git` is injectable so a caller can drive the classification against a
|
|
407
461
|
* substitute history; it defaults to the real git bound to `opts.cwd`.
|
|
408
462
|
*
|
|
409
|
-
*
|
|
463
|
+
* `opts.companions` overrides {@link COMPANION_SUBPATHS} — injectable so a
|
|
464
|
+
* fixture can exercise the shared-core relationship without depending on this
|
|
465
|
+
* repo's own action layout.
|
|
466
|
+
*
|
|
467
|
+
* @param {{cwd?: string, workflowsDir?: string, actionsDir?: string, firstPartyOwner?: string, ref?: string, companions?: Record<string, string[]>}} opts
|
|
410
468
|
* @param {ReturnType<typeof createGit>} [git]
|
|
411
469
|
* @returns {{ok: boolean, fatal: string|null, stale: object[], unreachable: object[], unpinnedRefs: object[], scanned: number, files: string[], headSha: string|null}}
|
|
412
470
|
*/
|
|
@@ -457,12 +515,15 @@ export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.
|
|
|
457
515
|
|
|
458
516
|
// Every call site for a subpath must move together, so the same
|
|
459
517
|
// (sha, subpath) pair is compared repeatedly — `setup-toolchain` alone has
|
|
460
|
-
// five. Resolve each tree once.
|
|
518
|
+
// five. Resolve each tree once. The companions are part of the key: two
|
|
519
|
+
// subpaths sharing a core must not collide, and a companion map override
|
|
520
|
+
// must not read a cache entry computed without it.
|
|
521
|
+
const companionMap = opts.companions || COMPANION_SUBPATHS;
|
|
461
522
|
const driftCache = new Map();
|
|
462
|
-
const driftFor = (sha, subpath) => {
|
|
463
|
-
const key = `${sha}:${subpath}`;
|
|
523
|
+
const driftFor = (sha, subpath, companions) => {
|
|
524
|
+
const key = `${sha}:${[subpath, ...companions].join(",")}`;
|
|
464
525
|
if (!driftCache.has(key)) {
|
|
465
|
-
driftCache.set(key, diffSubpathAtSha(git, repoRoot, sha, subpath));
|
|
526
|
+
driftCache.set(key, diffSubpathAtSha(git, repoRoot, sha, subpath, companions));
|
|
466
527
|
}
|
|
467
528
|
return driftCache.get(key);
|
|
468
529
|
};
|
|
@@ -511,12 +572,13 @@ export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.
|
|
|
511
572
|
continue;
|
|
512
573
|
}
|
|
513
574
|
|
|
514
|
-
const
|
|
575
|
+
const companions = companionsFor(pin.subpath, companionMap);
|
|
576
|
+
const drift = driftFor(pin.sha, pin.subpath, companions);
|
|
515
577
|
if (drift.length > 0) {
|
|
516
578
|
stale.push({
|
|
517
579
|
...pin,
|
|
518
580
|
manifest: manifest.path,
|
|
519
|
-
reason: describeDrift(pin.subpath, drift),
|
|
581
|
+
reason: describeDrift(pin.subpath, drift, companions),
|
|
520
582
|
});
|
|
521
583
|
}
|
|
522
584
|
}
|
|
@@ -37,8 +37,10 @@ import {
|
|
|
37
37
|
resolveManifest,
|
|
38
38
|
manifestsMatch,
|
|
39
39
|
diffSubpathAtSha,
|
|
40
|
+
companionsFor,
|
|
40
41
|
runCheck,
|
|
41
42
|
runCli,
|
|
43
|
+
COMPANION_SUBPATHS,
|
|
42
44
|
} from "./check-first-party-pin-freshness.mjs";
|
|
43
45
|
|
|
44
46
|
const OWNER = "test-owner/test-repo";
|
|
@@ -390,6 +392,165 @@ test("diffSubpathAtSha: a blob git cannot resolve at the pinned SHA is drift, no
|
|
|
390
392
|
assert.deepEqual(drift, [{ path, kind: "differs" }]);
|
|
391
393
|
});
|
|
392
394
|
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Story #389 — a subpath's COMPANIONS are compared alongside it
|
|
397
|
+
//
|
|
398
|
+
// The subpaths this checker knows are exactly the ones named on `uses:` lines,
|
|
399
|
+
// so the moment an action's behaviour moves into a sibling DIRECTORY the
|
|
400
|
+
// Story #379 blind spot reopens one level up. `osv-track-issue` is now a thin
|
|
401
|
+
// preset that executes `../track-issue/track-issue.mjs`, and nothing `uses:`
|
|
402
|
+
// the generic action — so without a companion map, a rewrite of that shared
|
|
403
|
+
// core leaves every preset call site reading fresh while the pinned SHA runs
|
|
404
|
+
// the old state machine.
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
const PRESET_SUBPATH = ".github/actions/osv-track-issue";
|
|
408
|
+
const CORE_SUBPATH = ".github/actions/track-issue";
|
|
409
|
+
const PRESET_COMPANIONS = { [PRESET_SUBPATH]: [CORE_SUBPATH] };
|
|
410
|
+
|
|
411
|
+
/** A workflow pinning `subpath@sha`, one step per call site. */
|
|
412
|
+
function workflowFor(subpath, sha) {
|
|
413
|
+
return [
|
|
414
|
+
"name: fixture",
|
|
415
|
+
"on:",
|
|
416
|
+
" push:",
|
|
417
|
+
" branches: [main]",
|
|
418
|
+
"jobs:",
|
|
419
|
+
" demo:",
|
|
420
|
+
" runs-on: ubuntu-latest",
|
|
421
|
+
" steps:",
|
|
422
|
+
` - uses: ${OWNER}/${subpath}@${sha}`,
|
|
423
|
+
"",
|
|
424
|
+
].join("\n");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* A repo shaped like the post-#389 split: a preset directory whose manifest
|
|
429
|
+
* runs a script in a sibling core directory that no `uses:` line names. Both
|
|
430
|
+
* are committed; the caller then edits the CORE in the working tree only.
|
|
431
|
+
*/
|
|
432
|
+
function makePresetRepo(label) {
|
|
433
|
+
const root = mkdtempSync(join(tmpdir(), `pinfresh-${label}-`));
|
|
434
|
+
git(root, "init", "-b", "main");
|
|
435
|
+
git(root, "config", "user.email", "fixture@example.invalid");
|
|
436
|
+
git(root, "config", "user.name", "Pin Freshness Fixture");
|
|
437
|
+
git(root, "config", "commit.gpgsign", "false");
|
|
438
|
+
git(root, "config", "core.hooksPath", join(root, ".no-hooks"));
|
|
439
|
+
|
|
440
|
+
put(root, `${CORE_SUBPATH}/action.yml`, SIBLING_MANIFEST);
|
|
441
|
+
put(root, `${CORE_SUBPATH}/track-issue.mjs`, "export const verdict = () => 'noop';\n");
|
|
442
|
+
put(root, `${PRESET_SUBPATH}/action.yml`, SIBLING_MANIFEST);
|
|
443
|
+
put(root, `${PRESET_SUBPATH}/osv-track-issue.mjs`, "import '../track-issue/track-issue.mjs';\n");
|
|
444
|
+
git(root, "add", "-A");
|
|
445
|
+
git(root, "commit", "-m", "split the tracker into a core and a preset");
|
|
446
|
+
const after = git(root, "rev-parse", "HEAD");
|
|
447
|
+
|
|
448
|
+
return { root, after };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
test("stale: an edit confined to the shared core marks every preset call site stale", () => {
|
|
452
|
+
const { root, after } = makePresetRepo("companion-stale");
|
|
453
|
+
track(root);
|
|
454
|
+
// Two call sites, both pinning the preset — neither names the core.
|
|
455
|
+
put(root, ".github/workflows/one.yml", workflowFor(PRESET_SUBPATH, after));
|
|
456
|
+
put(root, ".github/workflows/two.yml", workflowFor(PRESET_SUBPATH, after));
|
|
457
|
+
|
|
458
|
+
// The edit is confined to the core, and nothing under the preset changes.
|
|
459
|
+
put(root, `${CORE_SUBPATH}/track-issue.mjs`, "export const verdict = () => 'create';\n");
|
|
460
|
+
|
|
461
|
+
// The premise, asserted rather than assumed: without the companion map this
|
|
462
|
+
// fixture reads perfectly fresh — which is the blind spot, not a pass.
|
|
463
|
+
const blind = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: {} });
|
|
464
|
+
assert.equal(blind.ok, true, "premise: a preset-only comparison sees no drift");
|
|
465
|
+
|
|
466
|
+
const result = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: PRESET_COMPANIONS });
|
|
467
|
+
|
|
468
|
+
assert.equal(result.ok, false);
|
|
469
|
+
assert.equal(result.scanned, 2);
|
|
470
|
+
assert.equal(result.stale.length, 2, "every call site of the preset is stale, not just one");
|
|
471
|
+
assert.equal(result.unreachable.length, 0);
|
|
472
|
+
for (const f of result.stale) {
|
|
473
|
+
assert.equal(f.subpath, PRESET_SUBPATH);
|
|
474
|
+
assert.match(f.reason, /track-issue\.mjs/, "the report names the drifting shared file");
|
|
475
|
+
assert.match(f.reason, /shared code it executes/, "and explains why a sibling tree is in scope");
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("stale: a file added to the shared core since the pinned SHA is drift", () => {
|
|
480
|
+
const { root, after } = makePresetRepo("companion-added");
|
|
481
|
+
track(root);
|
|
482
|
+
put(root, ".github/workflows/one.yml", workflowFor(PRESET_SUBPATH, after));
|
|
483
|
+
put(root, `${CORE_SUBPATH}/helper.mjs`, "export const help = () => 1;\n");
|
|
484
|
+
git(root, "add", "-A");
|
|
485
|
+
|
|
486
|
+
const result = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: PRESET_COMPANIONS });
|
|
487
|
+
|
|
488
|
+
assert.equal(result.ok, false);
|
|
489
|
+
assert.match(result.stale[0].reason, /helper\.mjs/);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("clean: a pin carrying the current core AND preset exits 0", () => {
|
|
493
|
+
const { root, after } = makePresetRepo("companion-clean");
|
|
494
|
+
track(root);
|
|
495
|
+
put(root, ".github/workflows/one.yml", workflowFor(PRESET_SUBPATH, after));
|
|
496
|
+
|
|
497
|
+
const result = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: PRESET_COMPANIONS });
|
|
498
|
+
assert.equal(result.ok, true);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("a subpath with no companions is compared exactly as before", () => {
|
|
502
|
+
const { root, after } = makePresetRepo("companion-unmapped");
|
|
503
|
+
track(root);
|
|
504
|
+
// Pin the CORE directly — it has no companions of its own, so a preset edit
|
|
505
|
+
// must not leak into its comparison.
|
|
506
|
+
put(root, ".github/workflows/one.yml", workflowFor(CORE_SUBPATH, after));
|
|
507
|
+
put(root, `${PRESET_SUBPATH}/osv-track-issue.mjs`, "// rewritten preset\n");
|
|
508
|
+
|
|
509
|
+
const result = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: PRESET_COMPANIONS });
|
|
510
|
+
assert.equal(result.ok, true, "companions are directional, not symmetric");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("diffSubpathAtSha: companions are unioned into both sides of the comparison", () => {
|
|
514
|
+
const presetPath = `${PRESET_SUBPATH}/action.yml`;
|
|
515
|
+
const corePath = `${CORE_SUBPATH}/action.yml`;
|
|
516
|
+
const presetBody = readFileSync(join(REPO_ROOT, presetPath), "utf8");
|
|
517
|
+
// The pinned side knows only the preset; the working side also has the core.
|
|
518
|
+
const fake = {
|
|
519
|
+
lsTree: (_sha, root) => (root === PRESET_SUBPATH ? [presetPath] : []),
|
|
520
|
+
lsFiles: (root) => (root === PRESET_SUBPATH ? [presetPath] : [corePath]),
|
|
521
|
+
show: () => presetBody,
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const without = diffSubpathAtSha(fake, REPO_ROOT, "0".repeat(40), PRESET_SUBPATH);
|
|
525
|
+
assert.deepEqual(without, [], "the preset tree alone is identical at both ends");
|
|
526
|
+
|
|
527
|
+
const withCore = diffSubpathAtSha(fake, REPO_ROOT, "0".repeat(40), PRESET_SUBPATH, [CORE_SUBPATH]);
|
|
528
|
+
assert.deepEqual(withCore, [{ path: corePath, kind: "added" }]);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("companionsFor: unmapped subpaths stand alone, and trailing slashes resolve", () => {
|
|
532
|
+
assert.deepEqual(companionsFor(".github/actions/setup-toolchain"), []);
|
|
533
|
+
assert.deepEqual(companionsFor(PRESET_SUBPATH, PRESET_COMPANIONS), [CORE_SUBPATH]);
|
|
534
|
+
assert.deepEqual(companionsFor(`${PRESET_SUBPATH}/`, PRESET_COMPANIONS), [CORE_SUBPATH]);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("COMPANION_SUBPATHS: every mapped subpath and companion exists in this repo", () => {
|
|
538
|
+
// A map entry pointing at a path that no longer exists is a silently-dead
|
|
539
|
+
// guard — the pin would read fresh again with nothing flagging it.
|
|
540
|
+
for (const [subpath, companions] of Object.entries(COMPANION_SUBPATHS)) {
|
|
541
|
+
assert.ok(
|
|
542
|
+
resolveManifest(REPO_ROOT, subpath),
|
|
543
|
+
`${subpath} is mapped but resolves to no manifest`
|
|
544
|
+
);
|
|
545
|
+
for (const companion of companions) {
|
|
546
|
+
assert.ok(
|
|
547
|
+
resolveManifest(REPO_ROOT, companion),
|
|
548
|
+
`${subpath} names companion ${companion}, which resolves to no manifest`
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
|
|
393
554
|
// ---------------------------------------------------------------------------
|
|
394
555
|
// AC-7 — third-party / local / docker references are never classified
|
|
395
556
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
+
});
|
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
// clears. That contract is the pure `decideVerdict` function — these tests
|
|
7
7
|
// pin every branch of it, plus the marker round-trip and the gh-driven
|
|
8
8
|
// lookup, without any network access.
|
|
9
|
+
//
|
|
10
|
+
// Story #389 moved that state machine into the generic
|
|
11
|
+
// `.github/actions/track-issue` core and reduced this module to a preset over
|
|
12
|
+
// it. Every test below predates that move and is retained verbatim: passing
|
|
13
|
+
// unmodified is the evidence that the extraction preserved the OSV verdict
|
|
14
|
+
// contract rather than rewriting it. The sections appended at the end cover
|
|
15
|
+
// what the move newly put at risk — marker continuity against a LIVE advisory
|
|
16
|
+
// issue, and the findings-envelope adapter.
|
|
9
17
|
|
|
10
18
|
import { test } from "node:test";
|
|
11
19
|
import assert from "node:assert/strict";
|
|
@@ -15,8 +23,11 @@ import {
|
|
|
15
23
|
extractDigest,
|
|
16
24
|
digestMarker,
|
|
17
25
|
buildIssueBody,
|
|
26
|
+
buildEnvUpdates,
|
|
18
27
|
findTrackingIssue,
|
|
28
|
+
DIGEST_PREFIX,
|
|
19
29
|
TRACKER_MARKER,
|
|
30
|
+
TRACKER_MARKER_KEY,
|
|
20
31
|
} from "../.github/actions/osv-track-issue/osv-track-issue.mjs";
|
|
21
32
|
|
|
22
33
|
const issueWithDigest = (number, digest) => ({
|
|
@@ -89,3 +100,147 @@ test("findTrackingIssue returns null when nothing carries the marker", () => {
|
|
|
89
100
|
const runner = () => JSON.stringify([{ number: 1, body: "no marker" }]);
|
|
90
101
|
assert.equal(findTrackingIssue({ repo: "acme/app", labels: [] }, runner), null);
|
|
91
102
|
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Story #389 — marker continuity across the extraction
|
|
106
|
+
//
|
|
107
|
+
// These two strings are what a LIVE advisory issue in a consumer repo is keyed
|
|
108
|
+
// on. A changed byte in either would leave that issue undiscoverable, so the
|
|
109
|
+
// next scheduled run would open a SECOND one — the exact duplicate-spam
|
|
110
|
+
// failure this action exists to prevent. They are therefore asserted as
|
|
111
|
+
// literals, not derived from the module under test.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
test("the tracker marker is byte-identical to the live one", () => {
|
|
115
|
+
assert.equal(TRACKER_MARKER, "<!-- mandrel:osv-advisory-tracker -->");
|
|
116
|
+
assert.equal(TRACKER_MARKER_KEY, "mandrel:osv-advisory-tracker");
|
|
117
|
+
assert.equal(DIGEST_PREFIX, "mandrel:osv-advisory-digest:");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a rendered OSV body round-trips its digest through the live digest prefix", () => {
|
|
121
|
+
const body = buildIssueBody({
|
|
122
|
+
digest: "cafe1234-3",
|
|
123
|
+
summary: "| advisory | package |",
|
|
124
|
+
repo: "acme/app",
|
|
125
|
+
branch: "main",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
assert.ok(body.includes("<!-- mandrel:osv-advisory-tracker -->"));
|
|
129
|
+
assert.ok(body.includes("<!-- mandrel:osv-advisory-digest: cafe1234-3 -->"));
|
|
130
|
+
assert.equal(extractDigest(body), "cafe1234-3");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("the OSV body is unchanged by the extraction — markers, prose, then summary", () => {
|
|
134
|
+
// Pinned against the shape the pre-#389 module produced. A silent body
|
|
135
|
+
// rewrite would churn every consumer's tracking issue on the next run.
|
|
136
|
+
const body = buildIssueBody({
|
|
137
|
+
digest: "d-1",
|
|
138
|
+
summary: "SUMMARY-BLOCK",
|
|
139
|
+
repo: "acme/app",
|
|
140
|
+
branch: "trunk",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
assert.equal(
|
|
144
|
+
body,
|
|
145
|
+
[
|
|
146
|
+
"<!-- mandrel:osv-advisory-tracker -->",
|
|
147
|
+
"<!-- mandrel:osv-advisory-digest: d-1 -->",
|
|
148
|
+
"",
|
|
149
|
+
"Scheduled OSV advisory scan of `acme/app` (default branch `trunk`) found advisories at or",
|
|
150
|
+
"above the configured gate. This issue is maintained automatically by the",
|
|
151
|
+
"`advisory-scan.yml` reusable workflow — it is updated when the finding set changes",
|
|
152
|
+
"and closed automatically when the set clears. Do not edit the markers above.",
|
|
153
|
+
"",
|
|
154
|
+
"SUMMARY-BLOCK",
|
|
155
|
+
].join("\n"),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("an empty summary still renders the historical placeholder", () => {
|
|
160
|
+
const body = buildIssueBody({ digest: "d-1", summary: "", repo: "a/b", branch: "main" });
|
|
161
|
+
assert.match(body, /_\(no summary provided\)_/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Story #389 — the findings-envelope adapter
|
|
166
|
+
//
|
|
167
|
+
// This module no longer performs the upsert; it translates the osv-scan
|
|
168
|
+
// envelope into the generic TRACK_* contract and hands it to the shared core.
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/** Collapse the adapter's entry list into a lookup for assertion. */
|
|
172
|
+
const asMap = (entries) => Object.fromEntries(entries);
|
|
173
|
+
|
|
174
|
+
test("the adapter forwards the live marker and digest prefix verbatim", () => {
|
|
175
|
+
const env = asMap(
|
|
176
|
+
buildEnvUpdates({ digest: "abc123", counts: { blocking: 2 }, failOn: "high", summary: "s" }, {
|
|
177
|
+
OSV_TRACK_REPO: "acme/app",
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
assert.equal(env.TRACK_MARKER, "mandrel:osv-advisory-tracker");
|
|
182
|
+
assert.equal(env.TRACK_DIGEST_PREFIX, "mandrel:osv-advisory-digest:");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("the adapter passes the scanner's own digest rather than deriving one", () => {
|
|
186
|
+
// The scanner digest depends on finding identity (id + package + version +
|
|
187
|
+
// source); a derived one over a count-shaped label would churn on nothing.
|
|
188
|
+
const env = asMap(
|
|
189
|
+
buildEnvUpdates({ digest: "scanner-digest", counts: { blocking: 3 }, failOn: "high" }, {
|
|
190
|
+
OSV_TRACK_REPO: "acme/app",
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
assert.equal(env.TRACK_DIGEST, "scanner-digest");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a blocking count of zero yields an empty failing set", () => {
|
|
197
|
+
const env = asMap(
|
|
198
|
+
buildEnvUpdates({ digest: "empty-0", counts: { blocking: 0 }, failOn: "high" }, {
|
|
199
|
+
OSV_TRACK_REPO: "acme/app",
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
assert.deepEqual(JSON.parse(env.TRACK_FAILED_ITEMS), []);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a non-zero blocking count yields a non-empty failing set", () => {
|
|
206
|
+
const env = asMap(
|
|
207
|
+
buildEnvUpdates({ digest: "d", counts: { blocking: 4 }, failOn: "critical" }, {
|
|
208
|
+
OSV_TRACK_REPO: "acme/app",
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
const items = JSON.parse(env.TRACK_FAILED_ITEMS);
|
|
212
|
+
assert.equal(items.length, 1);
|
|
213
|
+
assert.match(items[0], /4 advisory finding\(s\) at or above the 'critical' gate/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("the adapter neither refreshes on an unchanged set nor comments on change", () => {
|
|
217
|
+
// Both knobs default to today's OSV posture, so the preset must set neither.
|
|
218
|
+
const env = asMap(buildEnvUpdates({ digest: "d", counts: { blocking: 1 } }, { OSV_TRACK_REPO: "a/b" }));
|
|
219
|
+
assert.equal(env.TRACK_UNCHANGED_BEHAVIOR, undefined);
|
|
220
|
+
assert.equal(env.TRACK_COMMENT_ON_CHANGE, undefined);
|
|
221
|
+
assert.equal(env.TRACK_RUN_URL, undefined, "no run link — the body stays byte-stable across runs");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("the adapter carries the caller's title, labels and branch through", () => {
|
|
225
|
+
const env = asMap(
|
|
226
|
+
buildEnvUpdates({ digest: "d", counts: { blocking: 1 } }, {
|
|
227
|
+
OSV_TRACK_REPO: "acme/app",
|
|
228
|
+
OSV_TRACK_BRANCH: "trunk",
|
|
229
|
+
OSV_TRACK_TITLE: "Custom title",
|
|
230
|
+
OSV_TRACK_LABELS: "security,ops",
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
assert.equal(env.TRACK_REPO, "acme/app");
|
|
235
|
+
assert.equal(env.TRACK_BRANCH, "trunk");
|
|
236
|
+
assert.equal(env.TRACK_TITLE, "Custom title");
|
|
237
|
+
assert.equal(env.TRACK_LABELS, "security,ops");
|
|
238
|
+
assert.match(env.TRACK_BODY_INTRO, /Scheduled OSV advisory scan of `acme\/app` \(default branch `trunk`\)/);
|
|
239
|
+
assert.match(env.TRACK_CLOSE_COMMENT, /no longer reports any finding at or above the gate/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("the adapter defaults the title to the documented one when the caller omits it", () => {
|
|
243
|
+
const env = asMap(buildEnvUpdates({ digest: "d", counts: { blocking: 1 } }, { OSV_TRACK_REPO: "a/b" }));
|
|
244
|
+
assert.equal(env.TRACK_TITLE, "OSV advisory scan — default branch findings");
|
|
245
|
+
assert.equal(env.TRACK_BRANCH, "main");
|
|
246
|
+
});
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Unit coverage for the generic single-issue failure tracker (Story #389).
|
|
2
|
+
//
|
|
3
|
+
// The whole point of this action is a SINGLE tracked issue that does not spam:
|
|
4
|
+
// it must open once, stay quiet while the failing set is unchanged, update only
|
|
5
|
+
// on a real change, and close when the set clears. That contract is the pure
|
|
6
|
+
// `decideVerdict` function — these tests pin every branch of it, plus the
|
|
7
|
+
// failing-set derivation, the digest, the marker round-trip and the gh-driven
|
|
8
|
+
// lookup, without any network access.
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test scripts/track-issue.test.mjs
|
|
11
|
+
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_CLOSE_COMMENT,
|
|
17
|
+
DEFAULT_INTRO,
|
|
18
|
+
buildIssueBody,
|
|
19
|
+
computeDigest,
|
|
20
|
+
decideVerdict,
|
|
21
|
+
defaultDigestPrefix,
|
|
22
|
+
digestMarker,
|
|
23
|
+
extractDigest,
|
|
24
|
+
failingJobs,
|
|
25
|
+
findTrackingIssue,
|
|
26
|
+
markerKey,
|
|
27
|
+
markerLine,
|
|
28
|
+
renderEnvEntry,
|
|
29
|
+
resolveConfig,
|
|
30
|
+
} from "../.github/actions/track-issue/track-issue.mjs";
|
|
31
|
+
|
|
32
|
+
const MARKER = "acme:nightly-tracker";
|
|
33
|
+
const PREFIX = defaultDigestPrefix(MARKER);
|
|
34
|
+
const OPTS = { digestPrefix: PREFIX };
|
|
35
|
+
|
|
36
|
+
/** An open tracked issue whose body carries `digest`. */
|
|
37
|
+
const issueWithDigest = (number, digest) => ({
|
|
38
|
+
number,
|
|
39
|
+
body: buildIssueBody({ marker: MARKER, digestPrefix: PREFIX, digest, detail: "…" }),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// AC-3 — the four-way state machine survives the extraction unchanged
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
test("CREATE when items are failing and no tracking issue is open", () => {
|
|
47
|
+
const v = decideVerdict(null, { failedCount: 2, digest: "abcd1234" }, OPTS);
|
|
48
|
+
assert.equal(v.action, "create");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("UPDATE when the failing-set digest changed since the issue was written", () => {
|
|
52
|
+
const existing = issueWithDigest(42, "abcd1234");
|
|
53
|
+
const v = decideVerdict(existing, { failedCount: 3, digest: "zzzz9999" }, OPTS);
|
|
54
|
+
assert.equal(v.action, "update");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("CLOSE when the failing set is now empty but an issue is still open", () => {
|
|
58
|
+
const existing = issueWithDigest(42, "abcd1234");
|
|
59
|
+
const v = decideVerdict(existing, { failedCount: 0, digest: "green-0" }, OPTS);
|
|
60
|
+
assert.equal(v.action, "close");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("NOOP when nothing is failing and there is no issue to close", () => {
|
|
64
|
+
const v = decideVerdict(null, { failedCount: 0, digest: "green-0" }, OPTS);
|
|
65
|
+
assert.equal(v.action, "noop");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// AC-4 — the never-a-second-issue invariant, asserted on the core
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
test("two consecutive failing runs against an open marked issue never yield CREATE", () => {
|
|
73
|
+
// Run 1 opens the issue; the tracker then finds it on every subsequent run.
|
|
74
|
+
assert.equal(decideVerdict(null, { failedCount: 1, digest: "d1" }, OPTS).action, "create");
|
|
75
|
+
const open = issueWithDigest(7, "d1");
|
|
76
|
+
|
|
77
|
+
// Run 2 — same failing set. Run 3 — a DIFFERENT failing set. Neither may
|
|
78
|
+
// create: a second issue is the duplicate-spam failure this action prevents.
|
|
79
|
+
const second = decideVerdict(open, { failedCount: 1, digest: "d1" }, OPTS);
|
|
80
|
+
const third = decideVerdict(open, { failedCount: 4, digest: "d2" }, OPTS);
|
|
81
|
+
|
|
82
|
+
assert.equal(second.action, "noop");
|
|
83
|
+
assert.equal(third.action, "update");
|
|
84
|
+
for (const v of [second, third]) assert.notEqual(v.action, "create");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("an unparseable body never yields CREATE while an issue is open", () => {
|
|
88
|
+
// A human editing the body away is not a licence to open a second issue: the
|
|
89
|
+
// marker still found the issue, so the worst case is a redundant UPDATE.
|
|
90
|
+
const mangled = { number: 9, body: "someone deleted the digest marker" };
|
|
91
|
+
assert.equal(decideVerdict(mangled, { failedCount: 2, digest: "d9" }, OPTS).action, "update");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// AC-5 — only `failure` counts; cancelled/skipped are not failures
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
test("failingJobs counts only entries whose result is exactly `failure`", () => {
|
|
99
|
+
const needs = {
|
|
100
|
+
build: { result: "success" },
|
|
101
|
+
test: { result: "failure" },
|
|
102
|
+
deploy: { result: "cancelled" },
|
|
103
|
+
docs: { result: "skipped" },
|
|
104
|
+
lint: { result: "failure" },
|
|
105
|
+
};
|
|
106
|
+
assert.deepEqual(failingJobs(needs), ["lint", "test"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a job-results payload of only cancelled/skipped never yields CREATE", () => {
|
|
110
|
+
// A self-hosted fleet going down produces a wall of `cancelled` — raising an
|
|
111
|
+
// issue for that is precisely the noise this tracker exists to avoid.
|
|
112
|
+
const noise = { a: { result: "cancelled" }, b: { result: "skipped" }, c: { result: "success" } };
|
|
113
|
+
const failed = failingJobs(noise);
|
|
114
|
+
assert.deepEqual(failed, []);
|
|
115
|
+
|
|
116
|
+
const digest = computeDigest(failed);
|
|
117
|
+
assert.equal(
|
|
118
|
+
decideVerdict(null, { failedCount: failed.length, digest }, OPTS).action,
|
|
119
|
+
"noop",
|
|
120
|
+
"no open issue → nothing to do",
|
|
121
|
+
);
|
|
122
|
+
assert.equal(
|
|
123
|
+
decideVerdict(issueWithDigest(3, "d1"), { failedCount: failed.length, digest }, OPTS).action,
|
|
124
|
+
"close",
|
|
125
|
+
"an open issue is closed, because nothing is actually failing",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("resolveConfig derives an empty failing set from an all-cancelled job payload", () => {
|
|
130
|
+
const cfg = resolveConfig({
|
|
131
|
+
TRACK_MARKER: MARKER,
|
|
132
|
+
TRACK_REPO: "acme/app",
|
|
133
|
+
TRACK_JOB_RESULTS: JSON.stringify({ a: { result: "cancelled" }, b: { result: "skipped" } }),
|
|
134
|
+
});
|
|
135
|
+
assert.deepEqual(cfg.failedItems, []);
|
|
136
|
+
assert.equal(cfg.digest, "green-0");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// AC-6 — unchanged-set behaviour is selectable, defaulting to today's posture
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
test("an unchanged digest is NOOP by default", () => {
|
|
144
|
+
const existing = issueWithDigest(42, "same-digest");
|
|
145
|
+
const v = decideVerdict(existing, { failedCount: 2, digest: "same-digest" }, OPTS);
|
|
146
|
+
assert.equal(v.action, "noop");
|
|
147
|
+
assert.equal(v.changed, false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("the same unchanged digest is UPDATE under `unchanged-behavior: refresh`", () => {
|
|
151
|
+
const existing = issueWithDigest(42, "same-digest");
|
|
152
|
+
const v = decideVerdict(
|
|
153
|
+
existing,
|
|
154
|
+
{ failedCount: 2, digest: "same-digest" },
|
|
155
|
+
{ ...OPTS, unchangedBehavior: "refresh" },
|
|
156
|
+
);
|
|
157
|
+
assert.equal(v.action, "update");
|
|
158
|
+
assert.equal(v.changed, false, "a refresh is not a change — it must not fire a change comment");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("resolveConfig defaults unchanged-behavior to noop and rejects an unknown value", () => {
|
|
162
|
+
const base = { TRACK_MARKER: MARKER, TRACK_REPO: "acme/app" };
|
|
163
|
+
assert.equal(resolveConfig(base).unchangedBehavior, "noop");
|
|
164
|
+
assert.equal(resolveConfig({ ...base, TRACK_UNCHANGED_BEHAVIOR: "refresh" }).unchangedBehavior, "refresh");
|
|
165
|
+
assert.equal(
|
|
166
|
+
resolveConfig({ ...base, TRACK_UNCHANGED_BEHAVIOR: "shout" }).unchangedBehavior,
|
|
167
|
+
"noop",
|
|
168
|
+
"an unrecognised value falls back to the quiet posture rather than spamming",
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Marker + digest contract
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
test("the action owns both marker lines and they round-trip through a body", () => {
|
|
177
|
+
const body = buildIssueBody({
|
|
178
|
+
marker: MARKER,
|
|
179
|
+
digestPrefix: PREFIX,
|
|
180
|
+
digest: "deadbeef",
|
|
181
|
+
intro: "watching the nightly",
|
|
182
|
+
detail: "one job is red",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
assert.ok(body.includes(markerLine(MARKER)));
|
|
186
|
+
assert.equal(extractDigest(body, PREFIX), "deadbeef");
|
|
187
|
+
assert.equal(extractDigest("no markers here", PREFIX), null);
|
|
188
|
+
assert.match(body, /watching the nightly/);
|
|
189
|
+
assert.match(body, /one job is red/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a marker supplied as a rendered comment is not double-wrapped", () => {
|
|
193
|
+
// A caller that hands over `<!-- k -->` must still produce `<!-- k -->` —
|
|
194
|
+
// a double-wrapped marker would never match the live issue again.
|
|
195
|
+
assert.equal(markerKey("<!-- acme:x -->"), "acme:x");
|
|
196
|
+
assert.equal(markerLine("<!-- acme:x -->"), "<!-- acme:x -->");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("the legacy `--!>` comment terminator is stripped, not left in the key", () => {
|
|
200
|
+
// `--!>` closes an HTML comment just as `-->` does. A stripper blind to it
|
|
201
|
+
// leaves a stray `!` in the key, so the rendered marker stops matching the
|
|
202
|
+
// live issue and the next run opens a second one.
|
|
203
|
+
assert.equal(markerKey("<!-- acme:x --!>"), "acme:x");
|
|
204
|
+
assert.equal(markerLine("<!-- acme:x --!>"), "<!-- acme:x -->");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("a digest marker closed with `--!>` is still recovered", () => {
|
|
208
|
+
assert.equal(extractDigest("<!-- acme:d: abc123 --!>", "acme:d:"), "abc123");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("extractDigest skips non-matching comments and needs a single token", () => {
|
|
212
|
+
const body = [
|
|
213
|
+
"<!-- acme:nightly-tracker -->",
|
|
214
|
+
"<!-- unrelated: note -->",
|
|
215
|
+
"<!-- acme:d: two tokens -->",
|
|
216
|
+
"<!-- acme:d: realdigest -->",
|
|
217
|
+
].join("\n");
|
|
218
|
+
assert.equal(extractDigest(body, "acme:d:"), "realdigest");
|
|
219
|
+
assert.equal(extractDigest("<!-- acme:d: -->", "acme:d:"), null);
|
|
220
|
+
assert.equal(extractDigest("<!-- acme:d: unterminated", "acme:d:"), null);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("the digest prefix defaults from the marker but is overridable", () => {
|
|
224
|
+
assert.equal(defaultDigestPrefix(MARKER), "acme:nightly-tracker-digest:");
|
|
225
|
+
assert.match(digestMarker("abc", "custom:pfx:"), /<!-- custom:pfx: abc -->/);
|
|
226
|
+
assert.equal(extractDigest(digestMarker("abc", "custom:pfx:"), "custom:pfx:"), "abc");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("a regex-special digest prefix is matched literally, not as a pattern", () => {
|
|
230
|
+
const prefix = "acme(v1).digest:";
|
|
231
|
+
assert.equal(extractDigest(digestMarker("xyz", prefix), prefix), "xyz");
|
|
232
|
+
assert.equal(extractDigest("<!-- acmeXv1Yxdigest: xyz -->", prefix), null);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("body defaults fill in when the caller supplies no prose", () => {
|
|
236
|
+
const body = buildIssueBody({ marker: MARKER, digestPrefix: PREFIX, digest: "d" });
|
|
237
|
+
assert.ok(body.includes(DEFAULT_INTRO));
|
|
238
|
+
assert.match(body, /_\(no detail provided\)_/);
|
|
239
|
+
assert.ok(!body.includes("Latest run:"), "an absent run-url omits the line entirely");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("a run-url is rendered only when supplied", () => {
|
|
243
|
+
const body = buildIssueBody({
|
|
244
|
+
marker: MARKER,
|
|
245
|
+
digestPrefix: PREFIX,
|
|
246
|
+
digest: "d",
|
|
247
|
+
runUrl: "https://example.invalid/run/1",
|
|
248
|
+
});
|
|
249
|
+
assert.match(body, /Latest run: https:\/\/example\.invalid\/run\/1/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Digest derivation
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
test("computeDigest is order-independent and de-duplicating", () => {
|
|
257
|
+
assert.equal(computeDigest(["b", "a"]), computeDigest(["a", "b"]));
|
|
258
|
+
assert.equal(computeDigest(["a", "a", "b"]), computeDigest(["a", "b"]));
|
|
259
|
+
assert.notEqual(computeDigest(["a"]), computeDigest(["a", "b"]));
|
|
260
|
+
assert.match(computeDigest(["a"]), /^[0-9a-f]{12}$/);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("computeDigest marks an empty set legibly rather than hashing nothing", () => {
|
|
264
|
+
assert.equal(computeDigest([]), "green-0");
|
|
265
|
+
assert.equal(computeDigest(undefined), "green-0");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Environment contract
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
test("failed-items takes precedence over job-results when both are supplied", () => {
|
|
273
|
+
const cfg = resolveConfig({
|
|
274
|
+
TRACK_MARKER: MARKER,
|
|
275
|
+
TRACK_REPO: "acme/app",
|
|
276
|
+
TRACK_FAILED_ITEMS: JSON.stringify(["explicit"]),
|
|
277
|
+
TRACK_JOB_RESULTS: JSON.stringify({ ignored: { result: "failure" } }),
|
|
278
|
+
});
|
|
279
|
+
assert.deepEqual(cfg.failedItems, ["explicit"]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("an explicit digest wins over the derived one", () => {
|
|
283
|
+
const cfg = resolveConfig({
|
|
284
|
+
TRACK_MARKER: MARKER,
|
|
285
|
+
TRACK_REPO: "acme/app",
|
|
286
|
+
TRACK_FAILED_ITEMS: JSON.stringify(["a"]),
|
|
287
|
+
TRACK_DIGEST: "caller-supplied",
|
|
288
|
+
});
|
|
289
|
+
assert.equal(cfg.digest, "caller-supplied");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("malformed failed-items is an error, not a silently-empty failing set", () => {
|
|
293
|
+
// Swallowing a parse error here would CLOSE a live tracked issue while the
|
|
294
|
+
// failures it names are still happening.
|
|
295
|
+
const cfg = resolveConfig({
|
|
296
|
+
TRACK_MARKER: MARKER,
|
|
297
|
+
TRACK_REPO: "acme/app",
|
|
298
|
+
TRACK_FAILED_ITEMS: "{not json",
|
|
299
|
+
});
|
|
300
|
+
assert.match(cfg.error, /TRACK_FAILED_ITEMS/);
|
|
301
|
+
|
|
302
|
+
const notAnArray = resolveConfig({
|
|
303
|
+
TRACK_MARKER: MARKER,
|
|
304
|
+
TRACK_REPO: "acme/app",
|
|
305
|
+
TRACK_FAILED_ITEMS: '{"a":1}',
|
|
306
|
+
});
|
|
307
|
+
assert.match(notAnArray.error, /JSON string array/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("malformed job-results is an error rather than an empty failing set", () => {
|
|
311
|
+
const cfg = resolveConfig({
|
|
312
|
+
TRACK_MARKER: MARKER,
|
|
313
|
+
TRACK_REPO: "acme/app",
|
|
314
|
+
TRACK_JOB_RESULTS: "{not json",
|
|
315
|
+
});
|
|
316
|
+
assert.match(cfg.error, /TRACK_JOB_RESULTS/);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("resolveConfig splits labels and defaults the quiet knobs", () => {
|
|
320
|
+
const cfg = resolveConfig({
|
|
321
|
+
TRACK_MARKER: MARKER,
|
|
322
|
+
TRACK_REPO: "acme/app",
|
|
323
|
+
TRACK_LABELS: " ci , ,tracking ",
|
|
324
|
+
});
|
|
325
|
+
assert.deepEqual(cfg.labels, ["ci", "tracking"]);
|
|
326
|
+
assert.equal(cfg.commentOnChange, false);
|
|
327
|
+
assert.equal(cfg.dryRun, false);
|
|
328
|
+
assert.equal(cfg.branch, "main");
|
|
329
|
+
assert.equal(cfg.closeComment, DEFAULT_CLOSE_COMMENT);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("renderEnvEntry escalates a multi-line value to the heredoc form", () => {
|
|
333
|
+
assert.equal(renderEnvEntry("K", "one"), "K=one\n");
|
|
334
|
+
const multi = renderEnvEntry("K", "one\ntwo");
|
|
335
|
+
assert.match(multi, /^K<<K_EOF_7f3a\none\ntwo\nK_EOF_7f3a\n$/);
|
|
336
|
+
assert.throws(() => renderEnvEntry("K", "a\nK_EOF_7f3a"), /delimiter/);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// gh lookup
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
test("findTrackingIssue confirms the marker rather than trusting the search hint", () => {
|
|
344
|
+
const calls = [];
|
|
345
|
+
const runner = (args, opts) => {
|
|
346
|
+
calls.push({ args, opts });
|
|
347
|
+
// gh's `in:body` search is fuzzy — return one true match and one false positive.
|
|
348
|
+
return JSON.stringify([
|
|
349
|
+
{ number: 99, body: "unrelated issue mentioning nightly-tracker in prose" },
|
|
350
|
+
{ number: 100, body: `${markerLine(MARKER)}\n${digestMarker("x-1", PREFIX)}\nbody` },
|
|
351
|
+
]);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const found = findTrackingIssue({ repo: "acme/app", labels: ["ci"], marker: MARKER }, runner);
|
|
355
|
+
|
|
356
|
+
assert.equal(found.number, 100);
|
|
357
|
+
assert.ok(calls[0].args.includes("--label"));
|
|
358
|
+
assert.ok(calls[0].args.includes("ci"));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("findTrackingIssue returns null when nothing carries the marker", () => {
|
|
362
|
+
const runner = () => JSON.stringify([{ number: 1, body: "no marker" }]);
|
|
363
|
+
assert.equal(findTrackingIssue({ repo: "acme/app", labels: [], marker: MARKER }, runner), null);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("findTrackingIssue surfaces a gh failure rather than reporting no issue", () => {
|
|
367
|
+
// Reporting "no issue" on a failed lookup would open a duplicate.
|
|
368
|
+
const runner = () => {
|
|
369
|
+
throw new Error("gh: not authenticated");
|
|
370
|
+
};
|
|
371
|
+
assert.throws(
|
|
372
|
+
() => findTrackingIssue({ repo: "acme/app", labels: [], marker: MARKER }, runner),
|
|
373
|
+
/gh issue list failed/,
|
|
374
|
+
);
|
|
375
|
+
});
|