mandrel-platform 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -12
- package/config/stryker.base.json +7 -2
- package/package.json +2 -2
- package/scripts/audit-check.mjs +331 -6
- package/scripts/audit-check.test.mjs +382 -1
- package/scripts/check-affected-mode.test.mjs +5 -51
- package/scripts/check-codeql-gating.test.mjs +649 -0
- package/scripts/check-destructive-migration.mjs +277 -11
- package/scripts/check-destructive-migration.test.mjs +334 -0
- package/scripts/check-environments-isolation-audit.test.mjs +212 -0
- package/scripts/check-fail-fast-attribution.test.mjs +90 -4
- package/scripts/check-first-party-pin-freshness.mjs +200 -22
- package/scripts/check-first-party-pin-freshness.test.mjs +296 -0
- package/scripts/check-gitleaks-allowlist.test.mjs +312 -0
- package/scripts/check-osv-scan-mode.test.mjs +5 -50
- package/scripts/check-release-type.mjs +591 -0
- package/scripts/check-release-type.test.mjs +678 -0
- package/scripts/check-setup-toolchain-store.test.mjs +139 -0
- package/scripts/check-toolchain-cache-default.test.mjs +308 -0
- package/scripts/lib/yaml-step.mjs +109 -0
- package/scripts/lib/yaml-step.test.mjs +156 -0
- package/scripts/osv-report-gate.test.mjs +289 -0
- package/scripts/osv-track-issue.test.mjs +155 -0
- package/scripts/stryker-base-config.test.mjs +256 -0
- package/scripts/track-issue.test.mjs +375 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* stryker-base-config.test.mjs — regression guard for the shared Stryker base
|
|
4
|
+
* config's bail and timeout shape.
|
|
5
|
+
*
|
|
6
|
+
* The bug this pins: with Stryker's default bail (`disableBail: false`), the
|
|
7
|
+
* vitest runner can mark a mutant Survived having completed zero tests — the
|
|
8
|
+
* mutant carries a non-empty covering-test list while its completed-test count
|
|
9
|
+
* is zero. A consumer measured 92 of 345 mutants flipping verdict between two
|
|
10
|
+
* identical runs, and a recorded score of 53.5% against a real 72.29%. A
|
|
11
|
+
* committed baseline taken under bail is therefore a floor under a number
|
|
12
|
+
* nothing measured: the gate is not merely noisy, it is wrong in the direction
|
|
13
|
+
* that hides surviving mutants.
|
|
14
|
+
*
|
|
15
|
+
* Disabling bail is not free — every mutant now runs its full covering set, so
|
|
16
|
+
* the run lengthens. The timeouts in this same base config must move with it,
|
|
17
|
+
* or the suite trades a wrong number for a silent overrun, and an overrun that
|
|
18
|
+
* preserves the prior result and exits clean is the same failure wearing a
|
|
19
|
+
* different mask. That coupling is why bail and the timeouts are asserted
|
|
20
|
+
* together here rather than in two independent tests: re-tightening either half
|
|
21
|
+
* alone reintroduces the defect.
|
|
22
|
+
*
|
|
23
|
+
* This repository ships the config but runs no mutation suite of its own, so
|
|
24
|
+
* the contract is asserted by shape. The run-to-run stability it buys is
|
|
25
|
+
* observable only in a consumer.
|
|
26
|
+
*
|
|
27
|
+
* The delivery half is asserted end-to-end rather than by shape: the suite
|
|
28
|
+
* imports the base through its published package specifier — the same
|
|
29
|
+
* resolution a consumer's `stryker.config.mjs` performs — and checks the
|
|
30
|
+
* settings that arrive. Stryker has no `extends` option (adjudicated against
|
|
31
|
+
* @stryker-mutator/core and @stryker-mutator/api 9.6.1), so the spread import
|
|
32
|
+
* is the only mechanism that delivers anything at all, and it is the only one
|
|
33
|
+
* documented.
|
|
34
|
+
*
|
|
35
|
+
* Run: node --test scripts/stryker-base-config.test.mjs
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import assert from "node:assert/strict";
|
|
39
|
+
import { test } from "node:test";
|
|
40
|
+
import { readFileSync } from "node:fs";
|
|
41
|
+
|
|
42
|
+
const CONFIG_PATH = "config/stryker.base.json";
|
|
43
|
+
const PACKAGE_PATH = "package.json";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The specifier a consumer's `stryker.config.mjs` imports. Node resolves it
|
|
47
|
+
* through this package's own `exports` map (self-reference), so importing it
|
|
48
|
+
* here exercises the same resolution a consumer gets rather than a stand-in
|
|
49
|
+
* for it.
|
|
50
|
+
*/
|
|
51
|
+
const PACKAGE_SPECIFIER = "mandrel-platform/stryker.base.json";
|
|
52
|
+
|
|
53
|
+
/** Stryker's own defaults, per https://stryker-mutator.io/docs/stryker-js/configuration. */
|
|
54
|
+
const STRYKER_DEFAULTS = Object.freeze({
|
|
55
|
+
timeoutMS: 5000,
|
|
56
|
+
timeoutFactor: 1.5,
|
|
57
|
+
dryRunTimeoutMinutes: 5,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The timeout floor this config carried *before* bail was disabled. Bail
|
|
62
|
+
* cut every mutant's run short, so 60s absolute was survivable; without it the
|
|
63
|
+
* covering set runs to completion and the budget has to grow. Asserting
|
|
64
|
+
* strictly above the old value is what makes "raised alongside the bail
|
|
65
|
+
* change" mechanically checkable rather than a claim in a commit message.
|
|
66
|
+
*/
|
|
67
|
+
const PRE_CHANGE_TIMEOUT_MS = 60000;
|
|
68
|
+
|
|
69
|
+
function readConfig() {
|
|
70
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test("base config disables bail so every mutant runs its full covering set", () => {
|
|
74
|
+
const config = readConfig();
|
|
75
|
+
|
|
76
|
+
assert.equal(
|
|
77
|
+
config.disableBail,
|
|
78
|
+
true,
|
|
79
|
+
`${CONFIG_PATH} must set "disableBail": true. Stryker defaults it to false, ` +
|
|
80
|
+
"and under bail the runner can score a mutant Survived having completed " +
|
|
81
|
+
"zero of its covering tests.",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("timeouts are raised above both Stryker's defaults and the pre-change floor", async (t) => {
|
|
86
|
+
const config = readConfig();
|
|
87
|
+
|
|
88
|
+
await t.test("timeoutMS clears the pre-bail-change floor", () => {
|
|
89
|
+
assert.equal(
|
|
90
|
+
typeof config.timeoutMS,
|
|
91
|
+
"number",
|
|
92
|
+
`${CONFIG_PATH} must pin "timeoutMS" explicitly, not inherit it.`,
|
|
93
|
+
);
|
|
94
|
+
assert.ok(
|
|
95
|
+
config.timeoutMS > PRE_CHANGE_TIMEOUT_MS,
|
|
96
|
+
`"timeoutMS" is ${config.timeoutMS}; it must exceed the pre-change ` +
|
|
97
|
+
`${PRE_CHANGE_TIMEOUT_MS} because disabling bail lengthens every mutant's run.`,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await t.test("timeoutFactor is pinned above Stryker's default", () => {
|
|
102
|
+
assert.equal(
|
|
103
|
+
typeof config.timeoutFactor,
|
|
104
|
+
"number",
|
|
105
|
+
`${CONFIG_PATH} must pin "timeoutFactor" explicitly, not inherit it.`,
|
|
106
|
+
);
|
|
107
|
+
assert.ok(
|
|
108
|
+
config.timeoutFactor > STRYKER_DEFAULTS.timeoutFactor,
|
|
109
|
+
`"timeoutFactor" is ${config.timeoutFactor}; it must exceed Stryker's ` +
|
|
110
|
+
`${STRYKER_DEFAULTS.timeoutFactor} default so a full covering set is not ` +
|
|
111
|
+
"clipped as a false Timeout.",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await t.test("dryRunTimeoutMinutes is pinned above Stryker's default", () => {
|
|
116
|
+
assert.equal(
|
|
117
|
+
typeof config.dryRunTimeoutMinutes,
|
|
118
|
+
"number",
|
|
119
|
+
`${CONFIG_PATH} must pin "dryRunTimeoutMinutes" explicitly, not inherit it.`,
|
|
120
|
+
);
|
|
121
|
+
assert.ok(
|
|
122
|
+
config.dryRunTimeoutMinutes > STRYKER_DEFAULTS.dryRunTimeoutMinutes,
|
|
123
|
+
`"dryRunTimeoutMinutes" is ${config.dryRunTimeoutMinutes}; it must exceed ` +
|
|
124
|
+
`Stryker's ${STRYKER_DEFAULTS.dryRunTimeoutMinutes}-minute default.`,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a Timeout is not silently absorbed into the score", () => {
|
|
130
|
+
const config = readConfig();
|
|
131
|
+
|
|
132
|
+
// `ignoreStatic` legitimately drops static mutants from the denominator.
|
|
133
|
+
// Nothing else may: an option that reclassifies or suppresses a timed-out or
|
|
134
|
+
// errored mutant would let a run that overran still report the prior number
|
|
135
|
+
// and exit clean — the exact failure disabling bail is meant to end.
|
|
136
|
+
assert.equal(
|
|
137
|
+
Object.hasOwn(config, "maxTestRunnerReuse"),
|
|
138
|
+
false,
|
|
139
|
+
'Do not pin "maxTestRunnerReuse" in the shared base; it masks runner-level ' +
|
|
140
|
+
"instability that the timeout budget is supposed to surface.",
|
|
141
|
+
);
|
|
142
|
+
assert.notEqual(
|
|
143
|
+
config.allowEmpty,
|
|
144
|
+
true,
|
|
145
|
+
'"allowEmpty" must stay false/absent: a dry run that executed no tests must ' +
|
|
146
|
+
"fail loudly rather than score an empty suite.",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("the documented spread-import mechanism delivers the bail-free settings", async () => {
|
|
151
|
+
// This is the consumer's own resolution path, not a proxy for it: the
|
|
152
|
+
// specifier below is resolved through the published `exports` map by Node,
|
|
153
|
+
// exactly as `stryker.config.mjs` in a consuming repo resolves it. Asserting
|
|
154
|
+
// the exports-map *string* instead would pass while the file it points at
|
|
155
|
+
// carried the wrong values, or while the entry was absent from `files` — the
|
|
156
|
+
// two ways the recipe can be documented correctly and still deliver nothing.
|
|
157
|
+
const { default: base } = await import(PACKAGE_SPECIFIER, {
|
|
158
|
+
with: { type: "json" },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
assert.equal(
|
|
162
|
+
base.disableBail,
|
|
163
|
+
true,
|
|
164
|
+
`Importing "${PACKAGE_SPECIFIER}" must yield "disableBail": true. A consumer ` +
|
|
165
|
+
"spreading this object into stryker.config.mjs gets whatever this " +
|
|
166
|
+
"resolves to, so a broken export or a stale published file silently " +
|
|
167
|
+
"restores bail.",
|
|
168
|
+
);
|
|
169
|
+
assert.ok(
|
|
170
|
+
base.timeoutMS > PRE_CHANGE_TIMEOUT_MS,
|
|
171
|
+
`Importing "${PACKAGE_SPECIFIER}" must also carry the raised timeout budget ` +
|
|
172
|
+
`that bail-free runs need; got timeoutMS ${base.timeoutMS}.`,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// The exports map must reach *this* file, or the assertions in the rest of
|
|
176
|
+
// this suite are guarding a config no consumer receives.
|
|
177
|
+
assert.deepEqual(
|
|
178
|
+
base,
|
|
179
|
+
readConfig(),
|
|
180
|
+
`"${PACKAGE_SPECIFIER}" must resolve to ${CONFIG_PATH} — the file every ` +
|
|
181
|
+
"other test here asserts.",
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("the config declares no `extends`, which Stryker does not support", () => {
|
|
186
|
+
const config = readConfig();
|
|
187
|
+
|
|
188
|
+
// Adjudicated against @stryker-mutator/core 9.6.1 and @stryker-mutator/api
|
|
189
|
+
// 9.6.1: the config reader loads exactly one config file and deep-merges CLI
|
|
190
|
+
// arguments over it — there is no extends resolution step anywhere in it —
|
|
191
|
+
// and `extends` is absent from the 45 top-level properties in the published
|
|
192
|
+
// stryker-core.json schema. A base that advertises an `extends` recipe sends
|
|
193
|
+
// consumers down a path where the settings below arrive not at all.
|
|
194
|
+
assert.equal(
|
|
195
|
+
Object.hasOwn(config, "extends"),
|
|
196
|
+
false,
|
|
197
|
+
`${CONFIG_PATH} must not declare "extends". Stryker has no such option; ` +
|
|
198
|
+
"the supported mechanism is importing this file by its package " +
|
|
199
|
+
"specifier and spreading it (see the README).",
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("annotations use the `_comment` suffix Stryker's validator exempts", () => {
|
|
204
|
+
const config = readConfig();
|
|
205
|
+
|
|
206
|
+
// Stryker warns "Unknown stryker config option \"<key>\"" for any top-level
|
|
207
|
+
// key that is neither in its schema nor suffixed `_comment`. A prefix-named
|
|
208
|
+
// key like `_comment_disableBail` fails that suffix check, so documenting
|
|
209
|
+
// the base costs every consumer a warning on every run.
|
|
210
|
+
const STRYKER_OPTIONS = new Set([
|
|
211
|
+
"$schema",
|
|
212
|
+
"packageManager",
|
|
213
|
+
"reporters",
|
|
214
|
+
"coverageAnalysis",
|
|
215
|
+
"ignoreStatic",
|
|
216
|
+
"cleanTempDir",
|
|
217
|
+
"disableBail",
|
|
218
|
+
"timeoutMS",
|
|
219
|
+
"timeoutFactor",
|
|
220
|
+
"dryRunTimeoutMinutes",
|
|
221
|
+
"thresholds",
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
const wouldWarn = Object.keys(config).filter(
|
|
225
|
+
(key) => !STRYKER_OPTIONS.has(key) && !key.endsWith("_comment"),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
assert.deepEqual(
|
|
229
|
+
wouldWarn,
|
|
230
|
+
[],
|
|
231
|
+
`${CONFIG_PATH} keys ${JSON.stringify(wouldWarn)} are neither pinned Stryker ` +
|
|
232
|
+
'options nor suffixed "_comment", so Stryker reports each as an unknown ' +
|
|
233
|
+
"config option in every consumer run. Rename annotations to " +
|
|
234
|
+
"`<topic>_comment`.",
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("every pinned Stryker option is still exported to consumers", () => {
|
|
239
|
+
const pkg = JSON.parse(readFileSync(PACKAGE_PATH, "utf8"));
|
|
240
|
+
|
|
241
|
+
// The import test above proves resolution works *here*, where Node's
|
|
242
|
+
// self-reference falls back to the local file. Publication is what carries it
|
|
243
|
+
// to a consumer, and that needs both the exports entry and the files
|
|
244
|
+
// allowlist.
|
|
245
|
+
assert.equal(
|
|
246
|
+
pkg.exports["./stryker.base.json"],
|
|
247
|
+
`./${CONFIG_PATH}`,
|
|
248
|
+
"The ./stryker.base.json export must point at the file this test asserts, " +
|
|
249
|
+
"or consumers import a config nothing guards.",
|
|
250
|
+
);
|
|
251
|
+
assert.ok(
|
|
252
|
+
pkg.files.includes("config/"),
|
|
253
|
+
'The "files" allowlist must publish config/, or the export resolves to a ' +
|
|
254
|
+
"file absent from the tarball.",
|
|
255
|
+
);
|
|
256
|
+
});
|
|
@@ -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
|
+
});
|