mandrel-platform 1.13.0 → 1.13.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/README.md +6 -2
- package/package.json +4 -2
- package/scripts/audit-check.mjs +210 -47
- package/scripts/audit-check.test.mjs +479 -49
- package/scripts/check-action-download-retries.mjs +155 -11
- package/scripts/check-action-download-retries.test.mjs +168 -3
- package/scripts/check-advisory-scan-setup.test.mjs +186 -5
- package/scripts/check-coverage-threshold.mjs +112 -17
- package/scripts/check-coverage-threshold.test.mjs +335 -0
- package/scripts/check-husky-hook-modes.test.mjs +134 -0
- package/scripts/check-runner-runs-on.test.mjs +223 -2
- package/scripts/check-semgrep-lockfile.test.mjs +238 -7
- package/scripts/check-workflow-lint-tier.test.mjs +176 -0
- package/scripts/check-workflow-portability.mjs +6 -3
- package/scripts/check-workflow-portability.test.mjs +1 -1
- package/scripts/env-doctor.mjs +493 -62
- package/scripts/env-doctor.test.mjs +529 -3
- package/scripts/fixtures/npm-audit-v2.json +66 -0
- package/scripts/install-git-hooks.mjs +123 -0
- package/scripts/install-git-hooks.test.mjs +160 -0
- package/scripts/issue-intake.test.mjs +351 -21
- package/scripts/runner-toggle.test.mjs +407 -0
- package/scripts/select-semgrep-python.sh +98 -17
- package/scripts/select-semgrep-python.test.mjs +139 -12
- package/scripts/update-semgrep-rules.mjs +83 -5
- package/scripts/update-semgrep-rules.test.mjs +55 -1
- package/scripts/workflow-lint-gate.test.mjs +128 -0
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
// • label discovery pages past the first 200, and a create refused as
|
|
10
10
|
// already-existing is a skip rather than a failure;
|
|
11
11
|
// • fire semantics INVERT on configuration — a configured-and-refused fire
|
|
12
|
-
// reds the run, an unwired one warns and stays green
|
|
12
|
+
// reds the run, an unwired one warns and stays green;
|
|
13
|
+
// • the run is IDEMPOTENT — the trigger label is re-read live before any
|
|
14
|
+
// write, because a re-run replays a payload that predates the first run's
|
|
15
|
+
// own label write, and an issue already wearing it is left alone;
|
|
16
|
+
// • the fire is BOUNDED by a race rather than a forwarded signal, which is
|
|
17
|
+
// precisely what the fakes below prove: every one of them ignores
|
|
18
|
+
// `init.signal`, exactly like a wedged endpoint's socket.
|
|
13
19
|
//
|
|
14
20
|
// Everything here runs offline: the `gh` adapter and `fetch` are injected
|
|
15
21
|
// seams, so even the behavioural claims are asserted without network access.
|
|
@@ -25,6 +31,8 @@ import { join } from "node:path";
|
|
|
25
31
|
import {
|
|
26
32
|
ANTHROPIC_BETA,
|
|
27
33
|
ANTHROPIC_VERSION,
|
|
34
|
+
BOOLEAN_INPUT_VALUES,
|
|
35
|
+
DEFAULT_FIRE_TIMEOUT_MS,
|
|
28
36
|
DEFAULT_LABEL_PREFIX,
|
|
29
37
|
DUPLICATE,
|
|
30
38
|
IGNORED,
|
|
@@ -47,11 +55,16 @@ import {
|
|
|
47
55
|
lookupPreset,
|
|
48
56
|
main,
|
|
49
57
|
matchesBodyShape,
|
|
58
|
+
parseBooleanInput,
|
|
50
59
|
parseLogins,
|
|
60
|
+
parsePayloadLabels,
|
|
61
|
+
readIssueLabels,
|
|
51
62
|
renderOutputEntry,
|
|
52
63
|
resolveConfig,
|
|
53
64
|
resolveFingerprint,
|
|
54
65
|
resolveFireOutcome,
|
|
66
|
+
resolveFireTimeoutMs,
|
|
67
|
+
resolveTriageState,
|
|
55
68
|
selectMissingLabels,
|
|
56
69
|
writeGithubOutput,
|
|
57
70
|
} from "../.github/actions/issue-intake/issue-intake.mjs";
|
|
@@ -62,13 +75,21 @@ const LOGINS = [PRODUCER, "second-bot"];
|
|
|
62
75
|
const PRESET = "structured-report";
|
|
63
76
|
const MATCHING_BODY = ["An alert fired in production.", "", "Fingerprint: alert-7731-cpu", ""].join("\n");
|
|
64
77
|
const NON_MATCHING_BODY = "Hey, the site felt slow this morning. Could someone look?";
|
|
78
|
+
const ISSUE_NUMBER = 412;
|
|
79
|
+
const TRIAGE_LABEL = "intake:triage";
|
|
80
|
+
// The fire endpoint is asserted through MARKER, never by substring-matching the
|
|
81
|
+
// URL itself: `js/incomplete-url-substring-sanitization` fires on a containment
|
|
82
|
+
// check against a URL-shaped constant, and it is a high-severity CodeQL block.
|
|
83
|
+
const FIRE_MARKER = "routine-endpoint";
|
|
84
|
+
const FIRE_URL = `https://${FIRE_MARKER}.test/v1/routines`;
|
|
85
|
+
const FIRE_TOKEN = "not-a-real-token-fixture";
|
|
65
86
|
|
|
66
87
|
// ---------------------------------------------------------------------------
|
|
67
88
|
// Fake `gh` runner — records every call so "wrote nothing" is assertable.
|
|
68
89
|
// ---------------------------------------------------------------------------
|
|
69
90
|
|
|
70
91
|
/**
|
|
71
|
-
* @param {{labels?: string[], duplicates?: Array<{number: number, body: string}>, createFails?: (name: string) => Error|null}} [opts]
|
|
92
|
+
* @param {{labels?: string[], issueLabels?: string[] | (() => string[]), duplicates?: Array<{number: number, body: string}>, createFails?: (name: string) => Error|null}} [opts]
|
|
72
93
|
*/
|
|
73
94
|
function fakeRunner(opts = {}) {
|
|
74
95
|
const labels = opts.labels ?? [];
|
|
@@ -78,6 +99,17 @@ function fakeRunner(opts = {}) {
|
|
|
78
99
|
const runner = (args, ctx) => {
|
|
79
100
|
calls.push({ args, ctx });
|
|
80
101
|
|
|
102
|
+
// The live read of ONE issue's labels. Matched before the repo-wide label
|
|
103
|
+
// page below, because `repos/<repo>/issues/<n>/labels` ends in `/labels`
|
|
104
|
+
// too — a fake that confused the two would answer the wrong question.
|
|
105
|
+
// Compared segment by segment rather than by substring or regex: CodeQL
|
|
106
|
+
// blocks both shapes on endpoint-like strings, and equality is clearer.
|
|
107
|
+
const endpoint = String(args[1]).split("/");
|
|
108
|
+
if (args[0] === "api" && endpoint.length === 6 && endpoint[3] === "issues" && endpoint[5] === "labels") {
|
|
109
|
+
const source = opts.issueLabels ?? [];
|
|
110
|
+
const names = typeof source === "function" ? source() : source;
|
|
111
|
+
return JSON.stringify(names.map((name) => ({ name })));
|
|
112
|
+
}
|
|
81
113
|
if (args[0] === "api" && String(args[1]).endsWith("/labels")) {
|
|
82
114
|
const pageArg = args.find((a) => String(a).startsWith("page="));
|
|
83
115
|
const page = Number(String(pageArg).slice("page=".length));
|
|
@@ -100,6 +132,7 @@ function fakeRunner(opts = {}) {
|
|
|
100
132
|
};
|
|
101
133
|
|
|
102
134
|
runner.calls = calls;
|
|
135
|
+
runner.reads = () => calls.filter(({ args }) => args[0] === "api");
|
|
103
136
|
runner.mutations = () =>
|
|
104
137
|
calls.filter(
|
|
105
138
|
({ args }) =>
|
|
@@ -140,6 +173,8 @@ const envFor = (overrides = {}) => ({
|
|
|
140
173
|
INTAKE_FIRE_URL: "",
|
|
141
174
|
INTAKE_FIRE_TOKEN: "",
|
|
142
175
|
INTAKE_DRY_RUN: "false",
|
|
176
|
+
// What a re-run replays: the labels as they stood when the issue was OPENED.
|
|
177
|
+
INTAKE_ISSUE_LABELS: "[]",
|
|
143
178
|
...overrides,
|
|
144
179
|
});
|
|
145
180
|
|
|
@@ -330,19 +365,24 @@ test("AC-3: label discovery pages past the first 200 (313-label fixture)", () =>
|
|
|
330
365
|
|
|
331
366
|
test("AC-3: an intake label sitting past page one is never re-created", () => {
|
|
332
367
|
const plan = intakeLabelPlan(DEFAULT_LABEL_PREFIX);
|
|
333
|
-
const labels = [
|
|
334
|
-
...Array.from({ length: 311 }, (_, i) => `label-${i}`),
|
|
335
|
-
plan[0].name,
|
|
336
|
-
plan[1].name,
|
|
337
|
-
];
|
|
368
|
+
const labels = [...Array.from({ length: 312 }, (_, i) => `label-${i}`), plan[0].name];
|
|
338
369
|
const runner = fakeRunner({ labels });
|
|
339
370
|
const result = ensureIntakeLabels({ repo: REPO, prefix: DEFAULT_LABEL_PREFIX }, runner);
|
|
340
371
|
|
|
341
372
|
assert.equal(result.discovered, 313);
|
|
342
|
-
assert.deepEqual(result.created, [plan[
|
|
373
|
+
assert.deepEqual(result.created, [plan[1].name], "only the genuinely absent label is created");
|
|
343
374
|
assert.deepEqual(result.skipped, []);
|
|
344
375
|
});
|
|
345
376
|
|
|
377
|
+
test("AC-3: `gh api` is scoped by its endpoint path, never by a --repo flag", () => {
|
|
378
|
+
const runner = fakeRunner({ labels: [] });
|
|
379
|
+
listRepoLabels({ repo: REPO }, runner);
|
|
380
|
+
|
|
381
|
+
const [{ args }] = runner.reads();
|
|
382
|
+
assert.ok(!args.includes("--repo"), "`gh api` exits non-zero on an unknown --repo flag");
|
|
383
|
+
assert.equal(args[1], `repos/${REPO}/labels`, "the repo is spelled into the endpoint instead");
|
|
384
|
+
});
|
|
385
|
+
|
|
346
386
|
test("AC-3: a create refused as already-existing returns success as a skip", () => {
|
|
347
387
|
const runner = fakeRunner({
|
|
348
388
|
labels: [],
|
|
@@ -354,7 +394,7 @@ test("AC-3: a create refused as already-existing returns success as a skip", ()
|
|
|
354
394
|
const result = ensureIntakeLabels({ repo: REPO, prefix: DEFAULT_LABEL_PREFIX }, runner);
|
|
355
395
|
|
|
356
396
|
assert.deepEqual(result.skipped, [labelFor(DEFAULT_LABEL_PREFIX, DUPLICATE)]);
|
|
357
|
-
assert.
|
|
397
|
+
assert.deepEqual(result.created, [labelFor(DEFAULT_LABEL_PREFIX, TRIAGE)]);
|
|
358
398
|
});
|
|
359
399
|
|
|
360
400
|
test("a genuine create failure is still an error", () => {
|
|
@@ -373,7 +413,48 @@ test("classifyLabelCreateFailure separates the benign refusal from a real failur
|
|
|
373
413
|
|
|
374
414
|
test("missing-label selection ignores case, matching GitHub's own collision rule", () => {
|
|
375
415
|
const plan = intakeLabelPlan("Intake");
|
|
376
|
-
assert.deepEqual(selectMissingLabels(["intake:triage", "
|
|
416
|
+
assert.deepEqual(selectMissingLabels(["intake:triage", "needs-triage"], plan), [plan[1]]);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// AC-6 — the label plan owns TWO labels
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
test("AC-6: the label plan owns exactly two labels, and `ignored` is not one", () => {
|
|
424
|
+
const plan = intakeLabelPlan(DEFAULT_LABEL_PREFIX);
|
|
425
|
+
assert.deepEqual(
|
|
426
|
+
plan.map((l) => l.name),
|
|
427
|
+
[labelFor(DEFAULT_LABEL_PREFIX, TRIAGE), labelFor(DEFAULT_LABEL_PREFIX, DUPLICATE)],
|
|
428
|
+
);
|
|
429
|
+
assert.ok(
|
|
430
|
+
!plan.some((l) => l.name === labelFor(DEFAULT_LABEL_PREFIX, IGNORED)),
|
|
431
|
+
"an `ignored` label could never be applied — the verdict is inert by invariant",
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("AC-6: only the two planned labels are ever created, on a repo with none", () => {
|
|
436
|
+
const runner = fakeRunner({ labels: [] });
|
|
437
|
+
const result = ensureIntakeLabels({ repo: REPO, prefix: DEFAULT_LABEL_PREFIX }, runner);
|
|
438
|
+
|
|
439
|
+
assert.deepEqual(result.created, ["intake:triage", "intake:duplicate"]);
|
|
440
|
+
assert.equal(
|
|
441
|
+
runner.calls.filter(({ args }) => args[0] === "label" && args[1] === "create").length,
|
|
442
|
+
2,
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("AC-6: an ignored verdict still writes nothing, with the label gone", async () => {
|
|
447
|
+
const runner = fakeRunner({ labels: [] });
|
|
448
|
+
const { result } = await withCapturedConsole(() =>
|
|
449
|
+
main(envFor({ INTAKE_ISSUE_AUTHOR: "stranger" }), {
|
|
450
|
+
runner,
|
|
451
|
+
fetchImpl: async () => {
|
|
452
|
+
throw new Error("an ignored issue must never fire");
|
|
453
|
+
},
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
assert.equal(result, 0);
|
|
457
|
+
assert.deepEqual(runner.calls, [], "not even the live label read is worth spending on it");
|
|
377
458
|
});
|
|
378
459
|
|
|
379
460
|
// ---------------------------------------------------------------------------
|
|
@@ -424,8 +505,8 @@ test("AC-5: a configured fire that is refused exits non-zero", async () => {
|
|
|
424
505
|
const { result, err } = await withCapturedConsole(() =>
|
|
425
506
|
main(
|
|
426
507
|
envFor({
|
|
427
|
-
INTAKE_FIRE_URL:
|
|
428
|
-
INTAKE_FIRE_TOKEN:
|
|
508
|
+
INTAKE_FIRE_URL: FIRE_URL,
|
|
509
|
+
INTAKE_FIRE_TOKEN: FIRE_TOKEN,
|
|
429
510
|
}),
|
|
430
511
|
{ runner, fetchImpl: async () => ({ ok: false, status: 503 }) },
|
|
431
512
|
),
|
|
@@ -458,8 +539,8 @@ test("AC-5: a configured fire that is accepted exits zero", async () => {
|
|
|
458
539
|
const { result } = await withCapturedConsole(() =>
|
|
459
540
|
main(
|
|
460
541
|
envFor({
|
|
461
|
-
INTAKE_FIRE_URL:
|
|
462
|
-
INTAKE_FIRE_TOKEN:
|
|
542
|
+
INTAKE_FIRE_URL: FIRE_URL,
|
|
543
|
+
INTAKE_FIRE_TOKEN: FIRE_TOKEN,
|
|
463
544
|
}),
|
|
464
545
|
{
|
|
465
546
|
runner,
|
|
@@ -474,7 +555,7 @@ test("AC-5: a configured fire that is accepted exits zero", async () => {
|
|
|
474
555
|
assert.equal(result, 0);
|
|
475
556
|
assert.equal(seen.length, 1);
|
|
476
557
|
assert.equal(seen[0].init.method, "POST");
|
|
477
|
-
assert.equal(seen[0].url,
|
|
558
|
+
assert.equal(seen[0].url, FIRE_URL);
|
|
478
559
|
assert.equal(seen[0].init.headers["anthropic-version"], ANTHROPIC_VERSION);
|
|
479
560
|
assert.equal(seen[0].init.headers["anthropic-beta"], ANTHROPIC_BETA);
|
|
480
561
|
assert.deepEqual(
|
|
@@ -518,7 +599,7 @@ test("the fire body is a single `text` field naming the issue, not quoting it",
|
|
|
518
599
|
|
|
519
600
|
test("a transport-level fire failure is a refusal, not a crash", async () => {
|
|
520
601
|
const outcome = await fireRoutine(
|
|
521
|
-
{ url:
|
|
602
|
+
{ url: FIRE_URL, token: "t", payload: "{}" },
|
|
522
603
|
async () => {
|
|
523
604
|
throw new Error("ECONNREFUSED");
|
|
524
605
|
},
|
|
@@ -534,8 +615,8 @@ test("a duplicate never re-fires — that storm is what dedupe exists to stop",
|
|
|
534
615
|
const { result } = await withCapturedConsole(() =>
|
|
535
616
|
main(
|
|
536
617
|
envFor({
|
|
537
|
-
INTAKE_FIRE_URL:
|
|
538
|
-
INTAKE_FIRE_TOKEN:
|
|
618
|
+
INTAKE_FIRE_URL: FIRE_URL,
|
|
619
|
+
INTAKE_FIRE_TOKEN: FIRE_TOKEN,
|
|
539
620
|
}),
|
|
540
621
|
{
|
|
541
622
|
runner,
|
|
@@ -548,15 +629,263 @@ test("a duplicate never re-fires — that storm is what dedupe exists to stop",
|
|
|
548
629
|
assert.equal(result, 0);
|
|
549
630
|
});
|
|
550
631
|
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
// AC-1 / AC-2 — the CURRENT labels decide, and they are read LIVE
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
|
|
636
|
+
test("AC-1: an issue already carrying the trigger label is inert on a re-run", async () => {
|
|
637
|
+
const runner = fakeRunner({ labels: [], issueLabels: [TRIAGE_LABEL] });
|
|
638
|
+
const { result, log } = await withCapturedConsole(() =>
|
|
639
|
+
main(envFor({ INTAKE_FIRE_URL: FIRE_URL, INTAKE_FIRE_TOKEN: FIRE_TOKEN }), {
|
|
640
|
+
runner,
|
|
641
|
+
fetchImpl: async () => {
|
|
642
|
+
throw new Error("an already-triaged issue must never wake a second routine");
|
|
643
|
+
},
|
|
644
|
+
}),
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
assert.equal(result, 0, "a re-run over settled work is a no-op, not a failure");
|
|
648
|
+
assert.deepEqual(runner.mutations(), [], "no label is created and none is applied");
|
|
649
|
+
assert.ok(
|
|
650
|
+
log.some((l) => l.includes("already carries")),
|
|
651
|
+
"the run says why it stopped, so a reader is not left guessing",
|
|
652
|
+
);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("AC-2: the LIVE read decides, not the payload a re-run replays", async () => {
|
|
656
|
+
// Exactly the re-run shape: `issues.opened` captured an unlabelled issue,
|
|
657
|
+
// and the first run's own label write happened after that snapshot.
|
|
658
|
+
const runner = fakeRunner({ labels: [], issueLabels: [TRIAGE_LABEL] });
|
|
659
|
+
const { result } = await withCapturedConsole(() =>
|
|
660
|
+
main(
|
|
661
|
+
envFor({
|
|
662
|
+
INTAKE_ISSUE_LABELS: "[]",
|
|
663
|
+
INTAKE_FIRE_URL: FIRE_URL,
|
|
664
|
+
INTAKE_FIRE_TOKEN: FIRE_TOKEN,
|
|
665
|
+
}),
|
|
666
|
+
{
|
|
667
|
+
runner,
|
|
668
|
+
fetchImpl: async () => {
|
|
669
|
+
throw new Error("the stale payload must not be allowed to re-fire");
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
),
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
assert.equal(result, 0);
|
|
676
|
+
assert.deepEqual(runner.mutations(), []);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("AC-2: a failed live read falls back to the payload, and says so", async () => {
|
|
680
|
+
const runner = fakeRunner({
|
|
681
|
+
labels: [],
|
|
682
|
+
issueLabels: () => {
|
|
683
|
+
throw new Error("HTTP 503: upstream unavailable");
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
const { result, err } = await withCapturedConsole(() =>
|
|
687
|
+
main(envFor({ INTAKE_ISSUE_LABELS: JSON.stringify([{ name: TRIAGE_LABEL }]) }), { runner }),
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
assert.equal(result, 0, "the payload still reports the issue as triaged");
|
|
691
|
+
assert.deepEqual(runner.mutations(), [], "so the fallback answer is honoured");
|
|
692
|
+
assert.ok(
|
|
693
|
+
err.some((l) => l.startsWith("::warning::") && l.includes("falling back")),
|
|
694
|
+
"taking the stale answer is never silent",
|
|
695
|
+
);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("AC-2: a live read that fails over an UNLABELLED payload still proceeds", async () => {
|
|
699
|
+
const runner = fakeRunner({
|
|
700
|
+
labels: [],
|
|
701
|
+
issueLabels: () => {
|
|
702
|
+
throw new Error("HTTP 503: upstream unavailable");
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
const { result } = await withCapturedConsole(() => main(envFor(), { runner }));
|
|
706
|
+
|
|
707
|
+
assert.equal(result, 0);
|
|
708
|
+
const edit = runner.calls.find(({ args }) => args[0] === "issue" && args[1] === "edit");
|
|
709
|
+
assert.ok(edit.args.includes(TRIAGE_LABEL), "a read outage must not strand intake entirely");
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("the live read asks about ONE issue, not the repo's label catalogue", () => {
|
|
713
|
+
const runner = fakeRunner({ issueLabels: ["bug", TRIAGE_LABEL] });
|
|
714
|
+
const names = readIssueLabels({ repo: REPO, issueNumber: ISSUE_NUMBER }, runner);
|
|
715
|
+
|
|
716
|
+
assert.deepEqual(names, ["bug", TRIAGE_LABEL]);
|
|
717
|
+
assert.equal(runner.calls[0].args[1], `repos/${REPO}/issues/${ISSUE_NUMBER}/labels`);
|
|
718
|
+
assert.ok(!runner.calls[0].args.includes("--repo"), "`gh api` takes no --repo flag");
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("the trigger-label comparison is case-insensitive, as GitHub's own is", () => {
|
|
722
|
+
const runner = fakeRunner({ issueLabels: ["Intake:Triage"] });
|
|
723
|
+
const state = resolveTriageState(
|
|
724
|
+
{ repo: REPO, issueNumber: ISSUE_NUMBER, triageLabel: TRIAGE_LABEL, payloadLabels: "[]" },
|
|
725
|
+
runner,
|
|
726
|
+
);
|
|
727
|
+
assert.equal(state.alreadyTriaged, true, "GitHub refuses two labels differing only in case");
|
|
728
|
+
assert.equal(state.source, "live");
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("payload labels parse from either shape, and unparseable input is empty", () => {
|
|
732
|
+
assert.deepEqual(parsePayloadLabels(JSON.stringify([{ name: "a" }, { name: "b" }])), ["a", "b"]);
|
|
733
|
+
assert.deepEqual(parsePayloadLabels(JSON.stringify(["a", "b"])), ["a", "b"]);
|
|
734
|
+
assert.deepEqual(parsePayloadLabels(""), []);
|
|
735
|
+
assert.deepEqual(parsePayloadLabels(undefined), []);
|
|
736
|
+
assert.deepEqual(parsePayloadLabels("{not json"), [], "the degraded path must not throw");
|
|
737
|
+
assert.deepEqual(parsePayloadLabels(JSON.stringify({ name: "a" })), []);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
// AC-3 — the fire is BOUNDED, by a race and not by a forwarded signal
|
|
742
|
+
// ---------------------------------------------------------------------------
|
|
743
|
+
|
|
744
|
+
test("AC-3: a fetch that never settles and ignores init.signal still times out", async () => {
|
|
745
|
+
const runner = fakeRunner({ labels: [] });
|
|
746
|
+
const started = Date.now();
|
|
747
|
+
const { result, err } = await withCapturedConsole(() =>
|
|
748
|
+
main(
|
|
749
|
+
envFor({
|
|
750
|
+
INTAKE_FIRE_URL: FIRE_URL,
|
|
751
|
+
INTAKE_FIRE_TOKEN: FIRE_TOKEN,
|
|
752
|
+
INTAKE_FIRE_TIMEOUT_MS: "60",
|
|
753
|
+
}),
|
|
754
|
+
{
|
|
755
|
+
runner,
|
|
756
|
+
// A wedged endpoint: the connection is accepted and nothing ever comes
|
|
757
|
+
// back. Forwarding `init.signal` alone would hang here forever.
|
|
758
|
+
fetchImpl: () => new Promise(() => {}),
|
|
759
|
+
},
|
|
760
|
+
),
|
|
761
|
+
);
|
|
762
|
+
const elapsed = Date.now() - started;
|
|
763
|
+
|
|
764
|
+
assert.equal(result, 1, "a bounded-out fire is a refusal, and a refusal reds the run");
|
|
765
|
+
assert.ok(elapsed < 2000, `the run must not outlive its bound (took ${elapsed}ms)`);
|
|
766
|
+
assert.ok(err.some((l) => l.startsWith("::error::") && l.includes("refused")));
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test("AC-3: the timeout is reported as such, not as a mystery transport error", async () => {
|
|
770
|
+
const outcome = await fireRoutine(
|
|
771
|
+
{ url: FIRE_URL, token: FIRE_TOKEN, payload: "{}", timeoutMs: 25 },
|
|
772
|
+
() => new Promise(() => {}),
|
|
773
|
+
);
|
|
774
|
+
assert.equal(outcome.delivered, false);
|
|
775
|
+
assert.match(outcome.detail, /no response within 25ms/);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("AC-3: an abort signal is still forwarded, for a fetch that honours one", async () => {
|
|
779
|
+
const seen = [];
|
|
780
|
+
await fireRoutine({ url: FIRE_URL, token: FIRE_TOKEN, payload: "{}", timeoutMs: 500 }, async (_u, init) => {
|
|
781
|
+
seen.push(init.signal);
|
|
782
|
+
return { ok: true, status: 200 };
|
|
783
|
+
});
|
|
784
|
+
assert.equal(seen.length, 1);
|
|
785
|
+
assert.ok(seen[0] instanceof AbortSignal, "a compliant fetch should still tear the socket down");
|
|
786
|
+
assert.equal(seen[0].aborted, false);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("AC-3: a fire well inside its bound is delivered normally", async () => {
|
|
790
|
+
const outcome = await fireRoutine(
|
|
791
|
+
{ url: FIRE_URL, token: FIRE_TOKEN, payload: "{}", timeoutMs: 1000 },
|
|
792
|
+
async () => ({ ok: true, status: 202 }),
|
|
793
|
+
);
|
|
794
|
+
assert.deepEqual(outcome, { delivered: true, detail: "HTTP 202" });
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("the fire timeout falls back to the production default on a nonsense value", () => {
|
|
798
|
+
assert.equal(resolveFireTimeoutMs("250"), 250);
|
|
799
|
+
assert.equal(resolveFireTimeoutMs(" 250 "), 250);
|
|
800
|
+
assert.equal(resolveFireTimeoutMs(""), DEFAULT_FIRE_TIMEOUT_MS);
|
|
801
|
+
assert.equal(resolveFireTimeoutMs(undefined), DEFAULT_FIRE_TIMEOUT_MS);
|
|
802
|
+
assert.equal(resolveFireTimeoutMs("soon"), DEFAULT_FIRE_TIMEOUT_MS);
|
|
803
|
+
assert.equal(resolveFireTimeoutMs("0"), DEFAULT_FIRE_TIMEOUT_MS, "a zero bound would refuse everything");
|
|
804
|
+
assert.equal(resolveFireTimeoutMs("-5"), DEFAULT_FIRE_TIMEOUT_MS);
|
|
805
|
+
assert.equal(resolveConfig(envFor({ INTAKE_FIRE_TIMEOUT_MS: "90" })).fireTimeoutMs, 90);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
// AC-4 / AC-5 — dry-run previews the LIVE verdict, and parses strictly
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
test("AC-4: a dry run previews `duplicate`, because the lookup is a read", async () => {
|
|
813
|
+
const runner = fakeRunner({
|
|
814
|
+
labels: [],
|
|
815
|
+
duplicates: [{ number: 88, body: "Fingerprint: alert-7731-cpu" }],
|
|
816
|
+
});
|
|
817
|
+
const { result, log } = await withCapturedConsole(() =>
|
|
818
|
+
main(envFor({ INTAKE_DRY_RUN: "true", INTAKE_FIRE_URL: FIRE_URL, INTAKE_FIRE_TOKEN: FIRE_TOKEN }), {
|
|
819
|
+
runner,
|
|
820
|
+
fetchImpl: async () => {
|
|
821
|
+
throw new Error("a dry run must not fire");
|
|
822
|
+
},
|
|
823
|
+
}),
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
assert.equal(result, 0);
|
|
827
|
+
assert.deepEqual(runner.mutations(), [], "a preview writes nothing");
|
|
828
|
+
assert.ok(
|
|
829
|
+
log.some((l) => l.includes(`${DUPLICATE} —`)),
|
|
830
|
+
"the preview reports the verdict the real run would reach",
|
|
831
|
+
);
|
|
832
|
+
assert.ok(
|
|
833
|
+
!log.some((l) => l.includes("would label issue #412 `intake:triage`")),
|
|
834
|
+
"a preview that skipped the lookup would print the wrong verdict",
|
|
835
|
+
);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test("AC-5: dry-run accepts either boolean in any casing", () => {
|
|
839
|
+
for (const raw of ["true", "True", "TRUE", " true "]) {
|
|
840
|
+
assert.equal(resolveConfig(envFor({ INTAKE_DRY_RUN: raw })).dryRun, true, raw);
|
|
841
|
+
}
|
|
842
|
+
for (const raw of ["false", "FALSE", "False", ""]) {
|
|
843
|
+
const cfg = resolveConfig(envFor({ INTAKE_DRY_RUN: raw }));
|
|
844
|
+
assert.equal(cfg.dryRun, false, raw);
|
|
845
|
+
assert.equal(cfg.error, null, raw);
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("AC-5: `dry-run: True` really does suppress the writes", async () => {
|
|
850
|
+
const runner = fakeRunner({ labels: [] });
|
|
851
|
+
const { result, log } = await withCapturedConsole(() =>
|
|
852
|
+
main(envFor({ INTAKE_DRY_RUN: "True" }), { runner }),
|
|
853
|
+
);
|
|
854
|
+
assert.equal(result, 0);
|
|
855
|
+
assert.deepEqual(runner.mutations(), [], "a strict `=== \"true\"` compare would have written here");
|
|
856
|
+
assert.ok(log.some((l) => l.includes("(dry-run)")));
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test("AC-5: an unrecognised dry-run value fails the run and names what is accepted", async () => {
|
|
860
|
+
const runner = fakeRunner({ labels: [] });
|
|
861
|
+
const { result, err } = await withCapturedConsole(() =>
|
|
862
|
+
main(envFor({ INTAKE_DRY_RUN: "yes" }), { runner }),
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
assert.equal(result, 1, "guessing a typo's meaning could turn a preview into a real run");
|
|
866
|
+
assert.deepEqual(runner.calls, [], "the refusal precedes every call");
|
|
867
|
+
const message = err.join("\n");
|
|
868
|
+
for (const accepted of BOOLEAN_INPUT_VALUES) assert.ok(message.includes(accepted), accepted);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
test("parseBooleanInput reports the offending value, and falls back safely", () => {
|
|
872
|
+
assert.deepEqual(parseBooleanInput("TRUE", { name: "dry-run" }), { value: true, error: null });
|
|
873
|
+
assert.deepEqual(parseBooleanInput(undefined, { name: "dry-run" }), { value: false, error: null });
|
|
874
|
+
const bad = parseBooleanInput("1", { name: "dry-run" });
|
|
875
|
+
assert.equal(bad.value, false, "the fallback is the safe reading, whatever the error does");
|
|
876
|
+
assert.match(String(bad.error), /dry-run must be one of true, false/);
|
|
877
|
+
assert.match(String(bad.error), /got "1"/);
|
|
878
|
+
});
|
|
879
|
+
|
|
551
880
|
// ---------------------------------------------------------------------------
|
|
552
881
|
// Configuration contract
|
|
553
882
|
// ---------------------------------------------------------------------------
|
|
554
883
|
|
|
555
|
-
test("resolveConfig defaults the label prefix and
|
|
556
|
-
const cfg = resolveConfig(envFor({ INTAKE_LABEL_PREFIX: "",
|
|
884
|
+
test("resolveConfig defaults the label prefix and the fire bound", () => {
|
|
885
|
+
const cfg = resolveConfig(envFor({ INTAKE_LABEL_PREFIX: "", INTAKE_FIRE_TIMEOUT_MS: "" }));
|
|
557
886
|
assert.equal(cfg.error, null);
|
|
558
887
|
assert.equal(cfg.labelPrefix, DEFAULT_LABEL_PREFIX);
|
|
559
|
-
assert.equal(cfg.
|
|
888
|
+
assert.equal(cfg.fireTimeoutMs, DEFAULT_FIRE_TIMEOUT_MS);
|
|
560
889
|
});
|
|
561
890
|
|
|
562
891
|
test("resolveConfig rejects each misconfiguration with a named reason", () => {
|
|
@@ -569,6 +898,7 @@ test("resolveConfig rejects each misconfiguration with a named reason", () => {
|
|
|
569
898
|
[{ INTAKE_PRODUCER_LOGINS: " , " }, /INTAKE_PRODUCER_LOGINS is required/],
|
|
570
899
|
[{ INTAKE_LABEL_PREFIX: "not a prefix!" }, /not a usable label-name prefix/],
|
|
571
900
|
[{ INTAKE_FIRE_URL: "https://x.test", INTAKE_FIRE_TOKEN: "" }, /needs its bearer token/],
|
|
901
|
+
[{ INTAKE_DRY_RUN: "yes" }, /dry-run must be one of true, false/],
|
|
572
902
|
];
|
|
573
903
|
for (const [overrides, shape] of cases) {
|
|
574
904
|
const cfg = resolveConfig(envFor(overrides));
|