forgedock 1.41.0 → 1.43.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/bin/engine-cli.mjs +82 -17
- package/bin/forgedock.mjs +12 -1
- package/bin/lean/autochildren.mjs +103 -0
- package/bin/lean/autochildren.test.mjs +126 -0
- package/bin/lean/batch.mjs +8 -2
- package/bin/lean/batch.test.mjs +29 -2
- package/bin/lean/conformance.mjs +121 -8
- package/bin/lean/conformance.test.mjs +132 -2
- package/bin/lean/fixround.test.mjs +9 -1
- package/bin/lean/guardgate.mjs +66 -7
- package/bin/lean/guardgate.test.mjs +109 -3
- package/bin/lean/heavy.test.mjs +11 -1
- package/bin/lean/lean.mjs +40 -16
- package/bin/lean/lean.test.mjs +66 -5
- package/bin/lean/orchestrate-children.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +51 -12
- package/bin/lean/postmerge.mjs +5 -2
- package/bin/lean/postmerge.test.mjs +7 -0
- package/bin/lean/preflight.mjs +21 -0
- package/bin/lean/preflight.test.mjs +15 -1
- package/bin/lean/prompts.mjs +19 -4
- package/bin/lean/schedd.mjs +79 -15
- package/bin/lean/schedd.test.mjs +92 -2
- package/bin/lean/selftest-gate.mjs +46 -0
- package/bin/lean/selftest-gate.test.mjs +40 -0
- package/bin/lean/selftest-live.mjs +248 -0
- package/bin/lean/selftest-live.test.mjs +154 -0
- package/bin/lean/selftest-nightly.mjs +81 -0
- package/bin/lean/selftest-nightly.test.mjs +84 -0
- package/bin/lean/selftest-static.mjs +81 -0
- package/bin/lean/selftest-static.test.mjs +32 -0
- package/bin/lean/toolstate.mjs +19 -3
- package/bin/lean/toolstate.test.mjs +22 -0
- package/bin/lean/verify.mjs +9 -1
- package/bin/lean/verify.test.mjs +4 -0
- package/bin/review-staging.mjs +28 -6
- package/commands/deploy-pr.md +3 -3
- package/commands/orchestrate.md +2 -2
- package/commands/review-pr-staging.md +2 -2
- package/docs/CONFIG.md +30 -2
- package/docs/RELEASE-NOTES.md +13 -0
- package/docs/SELFTEST.md +34 -0
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +7 -0
- package/scripts/review-pr-staging-scope.test.sh +3 -3
- package/scripts/review-prepare.sh +3 -0
- package/scripts/review-staging-pre.sh +88 -43
- package/scripts/review-staging-pre.test.sh +42 -1
package/bin/lean/schedd.test.mjs
CHANGED
|
@@ -8,11 +8,11 @@ import { homedir, tmpdir } from "node:os";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
10
|
import { openBatch, isLaneAlive, listBatches, SECRET_ENV } from "./batch.mjs";
|
|
11
|
-
import { startDaemon, scheddPaths, connectDaemon, request, SCHEDD_SCRIPT, mergeEnv, batchEnv, droppedEnv, sanitizePath, redactEnv, exitOnSignals } from "./schedd.mjs";
|
|
11
|
+
import { startDaemon, scheddPaths, connectDaemon, request, unitState, SCHEDD_SCRIPT, mergeEnv, batchEnv, droppedEnv, sanitizePath, redactEnv, exitOnSignals } from "./schedd.mjs";
|
|
12
12
|
import { schedDir } from "./sched.mjs";
|
|
13
13
|
import { readHeartbeat } from "./schedd-health.mjs";
|
|
14
14
|
import { formatTable, orchestrate } from "./orchestrate.mjs";
|
|
15
|
-
import { orchestrateFromCli } from "../engine-cli.mjs";
|
|
15
|
+
import { orchestrateFromCli, scheddFromCli } from "../engine-cli.mjs";
|
|
16
16
|
|
|
17
17
|
// #4503: this process, and the env its clients submit for their lanes, never resolve the host's `~/.forge/sched`.
|
|
18
18
|
process.env.FORGEDOCK_SCHED_DIR = tmpDir("schedd-home-");
|
|
@@ -637,6 +637,96 @@ describe("schedd due timer (#4627)", () => {
|
|
|
637
637
|
});
|
|
638
638
|
});
|
|
639
639
|
|
|
640
|
+
// #4775: a client never races a systemd-managed daemon; a stalled daemon is visible and bounded.
|
|
641
|
+
describe("schedd under load and restart (#4775)", () => {
|
|
642
|
+
const noSleep = () => Promise.resolve();
|
|
643
|
+
it("connectDaemon never spawns while the unit is active or activating, and starts an installed stopped unit instead of spawning", async () => {
|
|
644
|
+
for (const state of ["active", "activating", "reloading"]) {
|
|
645
|
+
let spawned = 0;
|
|
646
|
+
await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), waitMs: 0, sleep: noSleep, spawnDaemon: () => { spawned++; }, unit: () => state, start: () => { spawned++; } }), /did not start/);
|
|
647
|
+
assert.equal(spawned, 0, `${state}: nothing is started`);
|
|
648
|
+
}
|
|
649
|
+
const calls = [];
|
|
650
|
+
await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), waitMs: 0, sleep: noSleep, spawnDaemon: () => calls.push("spawn"), unit: () => "inactive", start: () => calls.push("unit") }), /did not start/);
|
|
651
|
+
assert.deepEqual(calls, ["unit"]);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("connectDaemon spawns the daemon itself only when no unit is installed; spawnDaemon:null starts nothing", async () => {
|
|
655
|
+
const calls = [];
|
|
656
|
+
await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), waitMs: 0, sleep: noSleep, spawnDaemon: () => calls.push("spawn"), unit: () => null, start: () => calls.push("unit") }), /did not start/);
|
|
657
|
+
assert.deepEqual(calls, ["spawn"]);
|
|
658
|
+
await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), spawnDaemon: null, unit: () => { throw new Error("not consulted"); } }), { code: "ENOENT" });
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("unitState reads the systemctl state (also from a non-zero exit) and is null without a unit file", () => {
|
|
662
|
+
assert.equal(unitState({ exists: () => false }), null);
|
|
663
|
+
assert.equal(unitState({ exists: () => true, run: () => "activating\n" }), "activating");
|
|
664
|
+
assert.equal(unitState({ exists: () => true, run: () => { throw Object.assign(new Error("exit 3"), { stdout: "inactive\n" }); } }), "inactive");
|
|
665
|
+
assert.equal(unitState({ exists: () => true, run: () => { throw Object.assign(new Error("none"), { code: "ENOENT" }); } }), null);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("request(timeoutMs) rejects a daemon that never answers instead of hanging", async () => {
|
|
669
|
+
const dir = tmpDir("schedd-mute-"), sock = scheddPaths(dir).sock;
|
|
670
|
+
const server = (await import("node:net")).createServer(() => { /* accepts, never answers */ });
|
|
671
|
+
await new Promise((r) => server.listen(sock, r));
|
|
672
|
+
try {
|
|
673
|
+
const c = await connectDaemon({ dir, spawnDaemon: null });
|
|
674
|
+
await assert.rejects(request(c, { op: "status" }, undefined, { timeoutMs: 80 }), { code: "schedd.timeout" });
|
|
675
|
+
} finally { server.close(); }
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("status reports per-batch last-progress age and pending adds, including an unhosted batch with un-admitted adds", async () => {
|
|
679
|
+
const root = tmpDir("schedd-st-b-"), dir = tmpDir("schedd-st-");
|
|
680
|
+
const mk = (id, files) => { mkdirSync(join(root, id), { recursive: true }); writeFileSync(join(root, id, "plan.json"), JSON.stringify({ daemon: true })); for (const [n, t] of Object.entries(files)) writeFileSync(join(root, id, n), t); };
|
|
681
|
+
mk("b-stuck", { "state.json": JSON.stringify({ order: [1] }), add: "[7,8]" });
|
|
682
|
+
mk("b-quiet", { "state.json": JSON.stringify({ order: [1] }) });
|
|
683
|
+
const d = await startDaemon({ dir, root, runBatch: async () => ({}), resumeOnStart: false, log: () => {} });
|
|
684
|
+
try {
|
|
685
|
+
const st = await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "status" });
|
|
686
|
+
assert.equal(st.batches.length, 1, "a quiet unhosted batch is not listed");
|
|
687
|
+
const [b] = st.batches;
|
|
688
|
+
assert.equal(b.id, "b-stuck");
|
|
689
|
+
assert.equal(b.running, false);
|
|
690
|
+
assert.deepEqual(b.pendingAdds, [7, 8]);
|
|
691
|
+
assert.ok(b.lastProgressAgeSec >= 0 && b.lastProgressAgeSec < 60);
|
|
692
|
+
} finally { await d.close(); }
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("a batch whose start stalls does not block another batch's request, and the stalled request answers an error", async () => {
|
|
696
|
+
const root = tmpDir("schedd-stall-b-"), dir = tmpDir("schedd-stall-");
|
|
697
|
+
for (const id of ["b-hung", "b-ok"]) { mkdirSync(join(root, id), { recursive: true }); writeFileSync(join(root, id, "plan.json"), JSON.stringify({ daemon: true })); writeFileSync(join(root, id, "state.json"), JSON.stringify({ order: [1] })); }
|
|
698
|
+
const d = await startDaemon({ dir, root, resumeOnStart: false, admitMs: 150, log: () => {},
|
|
699
|
+
runBatch: ({ argv, sink }) => (argv[1] === "b-hung" ? new Promise(() => {}) : Promise.resolve().then(() => { sink.batch({ id: argv[1], dir: join(root, argv[1]) }); return {}; })) });
|
|
700
|
+
try {
|
|
701
|
+
const ask = async (id) => request(await connectDaemon({ dir, spawnDaemon: null }), { op: "attach", id }).catch((e) => e);
|
|
702
|
+
const hung = ask("b-hung");
|
|
703
|
+
const ok = await ask("b-ok");
|
|
704
|
+
assert.notEqual(ok instanceof Error && /did not start/.test(ok.message), true, "the healthy batch is served");
|
|
705
|
+
const e = await hung;
|
|
706
|
+
assert.match(e.message, /did not start within 150 ms/);
|
|
707
|
+
} finally { await d.close(); }
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("the unit stands by while another daemon holds the lock instead of exiting 0 (no Restart=always loop)", async () => {
|
|
711
|
+
const dir = tmpDir("schedd-sb-"), root = tmpDir("schedd-sb-b-");
|
|
712
|
+
const owner = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: "ignore" });
|
|
713
|
+
const gone = new Promise((r) => owner.on("exit", r));
|
|
714
|
+
mkdirSync(dir, { recursive: true });
|
|
715
|
+
writeFileSync(scheddPaths(dir).lock, String(owner.pid)); // a live daemon of another process
|
|
716
|
+
const slept = [];
|
|
717
|
+
process.env.FORGEDOCK_SCHEDD_IDLE_MS = "100"; // the successor daemon exits when idle, so the call returns
|
|
718
|
+
process.env.FORGEDOCK_BATCH_DIR = root;
|
|
719
|
+
try {
|
|
720
|
+
const r = await Promise.race([
|
|
721
|
+
scheddFromCli([], { scheddDir: dir, standby: true, standbyMs: 1, sleep: async () => { slept.push(1); if (slept.length === 3) { owner.kill("SIGKILL"); await gone; } }, processOn: () => {}, exit: () => {}, batchDeps: {}, due: null }),
|
|
722
|
+
new Promise((res) => setTimeout(() => res("timeout"), 5000)),
|
|
723
|
+
]);
|
|
724
|
+
assert.ok(slept.length >= 3, "waited for the owner before retrying");
|
|
725
|
+
assert.notEqual(r, "timeout");
|
|
726
|
+
} finally { delete process.env.FORGEDOCK_SCHEDD_IDLE_MS; delete process.env.FORGEDOCK_BATCH_DIR; owner.kill("SIGKILL"); }
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
640
730
|
describe("#4762 win32 socket path", () => {
|
|
641
731
|
it("win32 derives a stable per-dir named pipe; other platforms keep the unix socket file", () => {
|
|
642
732
|
const a = scheddPaths("/tmp/a", "win32"), b = scheddPaths("/tmp/b", "win32");
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// FORGE:SELFTEST marker (#4865): the live selftest result on the staging->main PR, and the release gate over it.
|
|
4
|
+
import { isTrustedComment } from "../engine/phases.mjs";
|
|
5
|
+
|
|
6
|
+
// Anchored: the marker leads the comment (postMarker writes it first), so prose that quotes a marker never counts.
|
|
7
|
+
const MARKER_RE = /^\s*<!--\s*FORGE:SELFTEST\s+([^>]*?)\s*-->/;
|
|
8
|
+
|
|
9
|
+
/** @param {{result:"pass"|"fail", sha:string, step?:string}} m */
|
|
10
|
+
export function buildMarker({ result, sha, step }) {
|
|
11
|
+
return `<!-- FORGE:SELFTEST result=${result} sha=${sha} step=${step || "none"} -->`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @returns {{result:string, sha:string, step:string}|null} null for a body without a well-formed marker. */
|
|
15
|
+
export function parseMarker(body) {
|
|
16
|
+
const m = MARKER_RE.exec(String(body ?? ""));
|
|
17
|
+
if (!m) return null;
|
|
18
|
+
const kv = Object.fromEntries(m[1].split(/\s+/).map((p) => p.split("=")).filter((p) => p.length === 2));
|
|
19
|
+
if (!/^(pass|fail)$/.test(kv.result ?? "") || !/^[0-9a-f]{40}$/.test(kv.sha ?? "")) return null;
|
|
20
|
+
return { result: kv.result, sha: kv.sha, step: kv.step ?? "none" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fail closed: the newest marker must be a pass for exactly `headSha`. @param {string[]} comments comment bodies, oldest first */
|
|
24
|
+
export function selftestGate(comments, headSha) {
|
|
25
|
+
const marks = comments.map(parseMarker).filter(Boolean);
|
|
26
|
+
const last = marks[marks.length - 1];
|
|
27
|
+
if (!last) return { ok: false, reason: "no FORGE:SELFTEST result on the PR; run `forgedock selftest --live --repo <sandbox> --pr <N>`" };
|
|
28
|
+
if (last.result !== "pass") return { ok: false, reason: `the latest FORGE:SELFTEST failed at step ${last.step}` };
|
|
29
|
+
if (last.sha !== headSha) return { ok: false, reason: `the passing FORGE:SELFTEST is for ${last.sha.slice(0, 7)}, not the PR head ${String(headSha).slice(0, 7)}` };
|
|
30
|
+
return { ok: true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Bodies of the trusted comments of PR `pr` that carry a marker, oldest first (one JSON object per gh output line).
|
|
35
|
+
* Only comments `isTrustedComment` accepts count (maintainer association, a Bot, FORGE_TRAIL_TRUSTED_LOGINS): anyone can
|
|
36
|
+
* comment on a public PR, and a forged pass must not open the release gate. A gh failure throws (the caller fails closed).
|
|
37
|
+
*/
|
|
38
|
+
export async function readMarkerComments(gh, repo, pr) {
|
|
39
|
+
const out = await gh(["api", "--paginate", `repos/${repo}/issues/${pr}/comments`, "--jq", '.[] | select(.body | test("FORGE:SELFTEST")) | {body, author_association, user: {login: .user.login, type: .user.type}} | @json']);
|
|
40
|
+
return String(out).split("\n").filter(Boolean).flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } })
|
|
41
|
+
.filter(isTrustedComment).map((c) => String(c.body ?? ""));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function postMarker(gh, repo, pr, m) {
|
|
45
|
+
await gh(["pr", "comment", String(pr), "-R", repo, "--body", `${buildMarker(m)}\nLive selftest: ${m.result}${m.step ? ` (step ${m.step})` : ""}`]);
|
|
46
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { buildMarker, parseMarker, selftestGate, readMarkerComments, postMarker } from "./selftest-gate.mjs";
|
|
6
|
+
|
|
7
|
+
const H = "a".repeat(40), O = "b".repeat(40);
|
|
8
|
+
describe("FORGE:SELFTEST gate (#4865)", () => {
|
|
9
|
+
it("round-trips a marker", () => {
|
|
10
|
+
assert.deepEqual(parseMarker(buildMarker({ result: "fail", sha: H, step: "add" })), { result: "fail", sha: H, step: "add" });
|
|
11
|
+
});
|
|
12
|
+
it("accepts a passing marker for the head", () => assert.equal(selftestGate([buildMarker({ result: "pass", sha: H })], H).ok, true));
|
|
13
|
+
it("refuses missing, failing, stale and malformed markers", () => {
|
|
14
|
+
assert.equal(selftestGate([], H).ok, false);
|
|
15
|
+
assert.equal(selftestGate(["lgtm"], H).ok, false);
|
|
16
|
+
assert.match(selftestGate([buildMarker({ result: "fail", sha: H, step: "add" })], H).reason, /step add/);
|
|
17
|
+
assert.match(selftestGate([buildMarker({ result: "pass", sha: O })], H).reason, /not the PR head/);
|
|
18
|
+
assert.equal(selftestGate(["<!-- FORGE:SELFTEST result=pass sha=abc -->"], H).ok, false);
|
|
19
|
+
});
|
|
20
|
+
it("a marker quoted inside prose does not count", () => {
|
|
21
|
+
assert.equal(parseMarker(`see: ${buildMarker({ result: "pass", sha: H })}`), null);
|
|
22
|
+
assert.equal(selftestGate([buildMarker({ result: "fail", sha: H, step: "add" }), `quoting ${buildMarker({ result: "pass", sha: H })}`], H).ok, false);
|
|
23
|
+
});
|
|
24
|
+
it("a gh failure is not read as no marker", async () => {
|
|
25
|
+
await assert.rejects(readMarkerComments(async () => { throw new Error("HTTP 502"); }, "o/r", 5), /502/);
|
|
26
|
+
});
|
|
27
|
+
it("the newest marker wins", () => {
|
|
28
|
+
assert.equal(selftestGate([buildMarker({ result: "pass", sha: H }), buildMarker({ result: "fail", sha: H, step: "merge" })], H).ok, false);
|
|
29
|
+
assert.equal(selftestGate([buildMarker({ result: "fail", sha: H, step: "merge" }), buildMarker({ result: "pass", sha: H })], H).ok, true);
|
|
30
|
+
});
|
|
31
|
+
it("reads and posts through gh", async () => {
|
|
32
|
+
const calls = [];
|
|
33
|
+
const rows = [{ body: "x", author_association: "OWNER", user: { login: "m", type: "User" } }, { body: "forged", author_association: "NONE", user: { login: "drive-by", type: "User" } }, { body: "bot", author_association: "NONE", user: { login: "app[bot]", type: "Bot" } }];
|
|
34
|
+
const gh = async (a) => { calls.push(a); return a[0] === "api" ? rows.map((r) => JSON.stringify(r)).join("\n") + "\nnot json\n" : ""; };
|
|
35
|
+
assert.deepEqual(await readMarkerComments(gh, "o/r", 5), ["x", "bot"], "an untrusted commenter's marker is dropped");
|
|
36
|
+
await postMarker(gh, "o/r", 5, { result: "pass", sha: H });
|
|
37
|
+
assert.match(calls[1].at(-1), /FORGE:SELFTEST result=pass/);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// `forgedock selftest --live --repo <owner/sandbox> [--lang node|python] [--pr N]` (#4865): drives the real binaries
|
|
4
|
+
// (gh, git, forgedock orchestrate) against a disposable sandbox repo. The mocked unit tests missed #4859 (--add skipped
|
|
5
|
+
// every issue), #4860 (issue body wiped to FORGE:STATE), #4817 and #4818; each step below fails on one of those classes.
|
|
6
|
+
// All process access goes through `io` so the step logic is unit-tested with fakes; `realIo` is the live binding.
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import { batchRoot, listBatches, openBatch } from "./batch.mjs";
|
|
12
|
+
import { staticCheck, formatStatic, realGh } from "./selftest-static.mjs";
|
|
13
|
+
import { postMarker } from "./selftest-gate.mjs";
|
|
14
|
+
import { reportNightly } from "./selftest-nightly.mjs";
|
|
15
|
+
import { parseForgeYaml } from "../forge-utils.mjs";
|
|
16
|
+
|
|
17
|
+
export class StepError extends Error {
|
|
18
|
+
constructor(step, message) { super(message); this.step = step; }
|
|
19
|
+
}
|
|
20
|
+
const fail = (step, msg) => { throw new StepError(step, msg); };
|
|
21
|
+
|
|
22
|
+
/** Fixture per toolchain: base files plus the two trivial issues (a test that fails on the old code). */
|
|
23
|
+
export const FIXTURES = {
|
|
24
|
+
node: {
|
|
25
|
+
files: { "package.json": '{"name":"selftest-sandbox","private":true,"type":"module","scripts":{"test":"node --test"}}\n', "README.md": "ForgeDock selftest sandbox\n" },
|
|
26
|
+
a: { title: "selftest A: add a()", body: "Create `src/a.js` exporting `a()` that returns `\"a\"`, and `src/a.test.js` (node:test) asserting it. The test must fail on the current code." },
|
|
27
|
+
b: { title: "selftest B: add b()", body: "Create `src/b.js` exporting `b()` that returns `\"b\"`, and `src/b.test.js` (node:test) asserting it. The test must fail on the current code." },
|
|
28
|
+
},
|
|
29
|
+
python: {
|
|
30
|
+
files: { "pyproject.toml": '[project]\nname = "selftest-sandbox"\nversion = "0.0.0"\n', "README.md": "ForgeDock selftest sandbox\n" },
|
|
31
|
+
a: { title: "selftest A: add a()", body: "Create `a.py` with `def a(): return \"a\"` and `test_a.py` (pytest) asserting it. The test must fail on the current code." },
|
|
32
|
+
b: { title: "selftest B: add b()", body: "Create `b.py` with `def b(): return \"b\"` and `test_b.py` (pytest) asserting it. The test must fail on the current code." },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The issue body with its FORGE:STATE annotation removed. */
|
|
37
|
+
export const stripState = (body) => String(body ?? "").replace(/<!--\s*FORGE:STATE[\s\S]*?-->/g, "").trim();
|
|
38
|
+
|
|
39
|
+
const num = (out) => { const m = /\/issues\/(\d+)/.exec(String(out)) ?? /^(\d+)$/m.exec(String(out)); if (!m) throw new Error(`no issue number in: ${String(out).slice(0, 120)}`); return Number(m[1]); };
|
|
40
|
+
|
|
41
|
+
async function waitFor(ctx, step, what, cond, onTick) {
|
|
42
|
+
const { io, timeoutMs } = ctx, t0 = io.now();
|
|
43
|
+
for (;;) {
|
|
44
|
+
if (onTick) await onTick();
|
|
45
|
+
const v = await cond();
|
|
46
|
+
if (v) return v;
|
|
47
|
+
if (io.now() - t0 > timeoutMs) fail(step, `timed out waiting for ${what}`);
|
|
48
|
+
await io.sleep(ctx.pollMs);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Step "body": every issue body still holds its creation text (#4860). */
|
|
53
|
+
async function checkBodies(ctx, step = "body") {
|
|
54
|
+
for (const [name, n] of Object.entries(ctx.issues)) {
|
|
55
|
+
const now = JSON.parse(await ctx.io.gh(["issue", "view", String(n), "-R", ctx.repo, "--json", "body"])).body;
|
|
56
|
+
if (!stripState(now).includes(ctx.bodies[n])) fail(step, `issue ${name} (#${n}) lost its body text; now: ${JSON.stringify(stripState(now).slice(0, 80))}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const STEPS = [
|
|
61
|
+
{ name: "create", run: async (ctx) => {
|
|
62
|
+
const { io, repo, fx } = ctx;
|
|
63
|
+
for (const [path, content] of Object.entries(fx.files)) {
|
|
64
|
+
await io.gh(["api", "-X", "PUT", `repos/${repo}/contents/${path}`, "-f", `message=selftest fixture ${path}`, "-f", `content=${Buffer.from(content).toString("base64")}`]).catch(() => { /* already present */ });
|
|
65
|
+
}
|
|
66
|
+
const mk = async (t, body) => num(await io.gh(["issue", "create", "-R", repo, "--title", t, "--body", body]));
|
|
67
|
+
const a = await mk(fx.a.title, fx.a.body);
|
|
68
|
+
const b = await mk(fx.b.title, `${fx.b.body}\n\n**Depends on**: #${a}`);
|
|
69
|
+
ctx.issues = { A: a, B: b };
|
|
70
|
+
ctx.bodies = { [a]: fx.a.body, [b]: fx.b.body };
|
|
71
|
+
ctx.created.push(a, b);
|
|
72
|
+
} },
|
|
73
|
+
{ name: "add", run: async (ctx) => {
|
|
74
|
+
const { io, repo, issues } = ctx;
|
|
75
|
+
ctx.run = await io.orchestrate.start(repo, [issues.A], 2);
|
|
76
|
+
const r = await io.cli(["orchestrate", "--add", ctx.run.id, String(issues.B)]);
|
|
77
|
+
if (r.code !== 0) fail("add", `orchestrate --add exited ${r.code}: ${String(r.stderr || r.stdout).slice(0, 200)}`);
|
|
78
|
+
if (/unreadable|skipped/i.test(`${r.stdout}\n${r.stderr}`)) fail("add", `--add skipped #${issues.B}: ${String(r.stdout || r.stderr).slice(0, 200)}`);
|
|
79
|
+
const st = await waitFor(ctx, "add", `#${issues.B} in the batch`, async () => { const s = await io.orchestrate.state(ctx.run.id); return s?.order?.includes(issues.B) ? s : null; });
|
|
80
|
+
if (!(st.deps?.[issues.B] ?? []).includes(issues.A)) fail("add", `#${issues.B} does not wait on #${issues.A} in the DAG (deps: ${JSON.stringify(st.deps?.[issues.B] ?? [])})`);
|
|
81
|
+
} },
|
|
82
|
+
{ name: "body", run: (ctx) => checkBodies(ctx) },
|
|
83
|
+
{ name: "resume", run: async (ctx) => {
|
|
84
|
+
const { io, issues } = ctx;
|
|
85
|
+
const pid = async () => (await io.orchestrate.state(ctx.run.id))?.running?.[issues.A]?.pid ?? null;
|
|
86
|
+
const before = await waitFor(ctx, "resume", `a live lane for #${issues.A}`, pid);
|
|
87
|
+
await ctx.run.kill();
|
|
88
|
+
ctx.run = await io.orchestrate.resume(ctx.run.id);
|
|
89
|
+
await io.sleep(ctx.pollMs);
|
|
90
|
+
const st = await io.orchestrate.state(ctx.run.id), after = st?.running?.[issues.A]?.pid ?? null;
|
|
91
|
+
const done = st?.results?.[issues.A];
|
|
92
|
+
if (!done && after !== before) fail("resume", `#${issues.A} was launched again after --resume (pid ${before} -> ${after}), not re-adopted`);
|
|
93
|
+
await checkBodies(ctx, "body");
|
|
94
|
+
} },
|
|
95
|
+
{ name: "merge", run: async (ctx) => {
|
|
96
|
+
const { io, repo, issues } = ctx;
|
|
97
|
+
const res = await waitFor(ctx, "merge", "both lanes to finish", async () => {
|
|
98
|
+
const s = await io.orchestrate.state(ctx.run.id);
|
|
99
|
+
return s?.results?.[issues.A] && s?.results?.[issues.B] ? s.results : null;
|
|
100
|
+
}, () => checkBodies(ctx, "body"));
|
|
101
|
+
const merged = {};
|
|
102
|
+
for (const [k, n] of Object.entries(issues)) {
|
|
103
|
+
if (res[n].outcome !== "merged" || !res[n].pr) fail("merge", `#${n} ended ${res[n].outcome}, not merged`);
|
|
104
|
+
const pr = JSON.parse(await io.gh(["pr", "view", String(res[n].pr), "-R", repo, "--json", "mergedAt,state"]));
|
|
105
|
+
if (pr.state !== "MERGED") fail("merge", `PR #${res[n].pr} for ${k} is ${pr.state}`);
|
|
106
|
+
const is = JSON.parse(await io.gh(["issue", "view", String(n), "-R", repo, "--json", "state,labels"]));
|
|
107
|
+
if (is.state !== "CLOSED") fail("merge", `#${n} is not closed`);
|
|
108
|
+
if (!is.labels.some((l) => l.name === "workflow:merged")) fail("merge", `#${n} lacks the workflow:merged label`);
|
|
109
|
+
merged[k] = Date.parse(pr.mergedAt);
|
|
110
|
+
}
|
|
111
|
+
if (!(merged.A <= merged.B)) fail("merge", `B merged before A (${merged.B} < ${merged.A})`);
|
|
112
|
+
} },
|
|
113
|
+
{ name: "remote", run: async (ctx) => {
|
|
114
|
+
const { io } = ctx;
|
|
115
|
+
if (!io.remote) { ctx.notes.push("remote: skipped (pool not configured)"); return; }
|
|
116
|
+
const c = num(await io.gh(["issue", "create", "-R", ctx.repo, "--title", `${ctx.fx.a.title.replace("A:", "C:")}`, "--body", ctx.fx.a.body]));
|
|
117
|
+
ctx.created.push(c);
|
|
118
|
+
const r = await io.remote.run({ repo: ctx.repo, issue: c, timeoutMs: ctx.timeoutMs, pollMs: ctx.pollMs });
|
|
119
|
+
if (r.where !== "remote") fail("remote", `#${c} ran ${r.where ?? "unknown"}, not in the pool`);
|
|
120
|
+
if (!r.authenticated) fail("remote", "the remote lane did not authenticate");
|
|
121
|
+
const cli = await io.cliVersion();
|
|
122
|
+
if (r.engineVersion !== cli) fail("remote", `sandbox engine ${r.engineVersion} != CLI ${cli}`);
|
|
123
|
+
} },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run the steps in order; the first failure stops them, cleanup always runs.
|
|
128
|
+
* @returns {{ok:boolean, step:string|null, message:string|null, notes:string[], cleanupErrors:string[]}}
|
|
129
|
+
*/
|
|
130
|
+
export async function runSelftestLive({ repo, lang = "node", io, timeoutMs = 45 * 60 * 1000, pollMs = 5000 }) {
|
|
131
|
+
const fx = FIXTURES[lang];
|
|
132
|
+
if (!fx) return { ok: false, step: "setup", message: `unknown fixture language ${lang} (node|python)`, notes: [], cleanupErrors: [] };
|
|
133
|
+
const ctx = { io, repo, fx, timeoutMs, pollMs, issues: {}, bodies: {}, created: [], notes: [], run: null };
|
|
134
|
+
let out = { ok: true, step: null, message: null };
|
|
135
|
+
for (const s of STEPS) {
|
|
136
|
+
try { await s.run(ctx); } catch (e) { out = { ok: false, step: e.step ?? s.name, message: String(e.message ?? e) }; break; }
|
|
137
|
+
}
|
|
138
|
+
const cleanupErrors = [];
|
|
139
|
+
const tryC = async (what, f) => { try { await f(); } catch (e) { cleanupErrors.push(`${what}: ${String(e.message ?? e).slice(0, 120)}`); } };
|
|
140
|
+
if (ctx.run) await tryC("stop orchestrate", () => ctx.run.kill());
|
|
141
|
+
for (const n of ctx.created) {
|
|
142
|
+
await tryC(`close #${n}`, () => io.gh(["issue", "close", String(n), "-R", repo]));
|
|
143
|
+
await tryC(`delete branches of #${n}`, async () => {
|
|
144
|
+
const refs = String(await io.gh(["api", `repos/${repo}/git/matching-refs/heads/forge/issue-${n}`, "--jq", ".[].ref"])).split("\n").filter(Boolean);
|
|
145
|
+
for (const ref of refs) await io.gh(["api", "-X", "DELETE", `repos/${repo}/git/${ref}`]);
|
|
146
|
+
});
|
|
147
|
+
await tryC(`close PRs of #${n}`, async () => {
|
|
148
|
+
const prs = String(await io.gh(["pr", "list", "-R", repo, "--search", `#${n} in:title,body`, "--state", "open", "--json", "number", "--jq", ".[].number"])).split("\n").filter(Boolean);
|
|
149
|
+
for (const p of prs) await io.gh(["pr", "close", p, "-R", repo, "--delete-branch"]);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return { ...out, notes: ctx.notes, cleanupErrors };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── real io + CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "forgedock.mjs");
|
|
157
|
+
|
|
158
|
+
export function realIo(env = process.env, { pool = false } = {}) {
|
|
159
|
+
let self;
|
|
160
|
+
const root = batchRoot(env);
|
|
161
|
+
const detached = (args) => { const p = spawn(process.execPath, [CLI, ...args], { detached: true, stdio: "ignore", env }); p.unref(); return { kill: async () => { try { process.kill(-p.pid, "SIGTERM"); } catch { /* gone */ } } }; };
|
|
162
|
+
const waitBatch = async (before) => { for (let i = 0; i < 60; i++) { const id = listBatches(root).find((b) => !before.has(b)); if (id) return id; await new Promise((r) => setTimeout(r, 1000)); } throw new Error("orchestrate did not create a batch within 60s"); };
|
|
163
|
+
return (self = {
|
|
164
|
+
now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
165
|
+
gh: async (args) => { const r = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); if (r.status !== 0) throw new Error(r.stderr.trim() || `gh exited ${r.status}`); return r.stdout; },
|
|
166
|
+
cli: async (args) => { const r = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8", env }); return { code: r.status ?? 1, stdout: r.stdout, stderr: r.stderr }; },
|
|
167
|
+
cliVersion: async () => JSON.parse(readFileSync(join(dirname(CLI), "..", "package.json"), "utf8")).version,
|
|
168
|
+
orchestrate: {
|
|
169
|
+
start: async (repo, issues, parallel) => {
|
|
170
|
+
const before = new Set(listBatches(root)), h = detached(["orchestrate", ...issues.map(String), "--parallel", String(parallel), "--repo", repo]);
|
|
171
|
+
return { id: await waitBatch(before), kill: h.kill };
|
|
172
|
+
},
|
|
173
|
+
resume: async (id) => ({ id, ...detached(["orchestrate", "--resume", id]) }),
|
|
174
|
+
state: async (id) => openBatch(root, id).store.load(),
|
|
175
|
+
},
|
|
176
|
+
// Step 6: one lane through the pool. The issue's FORGE:STATE records the placement and the lane's FORGE:TELEMETRY rows
|
|
177
|
+
// carry the engine's forge_version (the sandbox's, not the CLI's: the #4818 stale-image defect).
|
|
178
|
+
remote: pool ? { run: async ({ repo, issue, timeoutMs, pollMs }) => {
|
|
179
|
+
const run = await self.orchestrate.start(repo, [issue], 1), t0 = Date.now();
|
|
180
|
+
let row = null;
|
|
181
|
+
try {
|
|
182
|
+
while (!row && Date.now() - t0 < timeoutMs) { row = (await self.orchestrate.state(run.id))?.results?.[issue] ?? null; if (!row) await self.sleep(pollMs); }
|
|
183
|
+
} finally { await run.kill(); }
|
|
184
|
+
if (!row) throw new Error(`#${issue} produced no result within ${Math.round(timeoutMs / 1000)}s`);
|
|
185
|
+
const v = JSON.parse(await self.gh(["issue", "view", String(issue), "-R", repo, "--json", "body,comments"]));
|
|
186
|
+
const text = `${v.body}\n${v.comments.map((c) => c.body).join("\n")}`;
|
|
187
|
+
const versions = [...text.matchAll(/"forge_version":"([^"]+)"/g)].map((m) => m[1]);
|
|
188
|
+
return { where: /"placement":\{"where":"(\w+)"/.exec(text)?.[1] ?? null, authenticated: versions.length > 0 && !/^env\./.test(String(row.code ?? row.outcome ?? "")), engineVersion: versions.at(-1) ?? null };
|
|
189
|
+
} } : null,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The value after flag `f`, or null when absent. A flag with no value (end of argv, or another `--flag` next) throws. */
|
|
194
|
+
const flag = (argv, f) => {
|
|
195
|
+
const i = argv.indexOf(f);
|
|
196
|
+
if (i < 0) return null;
|
|
197
|
+
const v = argv[i + 1];
|
|
198
|
+
if (v == null || String(v).startsWith("--") || !String(v).trim()) throw new Error(`${f} needs a value`);
|
|
199
|
+
return v;
|
|
200
|
+
};
|
|
201
|
+
const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
202
|
+
const repoOrNull = (v) => (REPO_RE.test(String(v ?? "").trim()) ? String(v).trim() : null);
|
|
203
|
+
|
|
204
|
+
/** `forgedock selftest [--static] | --live --repo <o/sandbox> [--lang node|python] [--pr N]`; non-zero exit names the failed step. */
|
|
205
|
+
export async function selftestFromCli(argv, deps = {}) {
|
|
206
|
+
const cwd = deps.cwd ?? process.cwd(), env = deps.env ?? process.env, print = deps.print ?? ((l) => console.log(l));
|
|
207
|
+
const usage = "usage: forgedock selftest [--static] | --live [--repo <owner/sandbox>] [--lang node|python] [--pr <staging->main PR>] (sandbox repo from --repo or forge.yaml selftest.sandbox_repo)";
|
|
208
|
+
if (!argv.includes("--live") && !argv.includes("--static")) throw new Error(usage);
|
|
209
|
+
const nightlyRepo = argv.includes("--nightly-report") ? flag(argv, "--nightly-report") : null;
|
|
210
|
+
if (nightlyRepo) {
|
|
211
|
+
// #4894: the detached nightly run. Any failure, including one before the harness starts (no sandbox repo), is filed.
|
|
212
|
+
const rest = argv.filter((a, i) => a !== "--nightly-report" && argv[i - 1] !== "--nightly-report");
|
|
213
|
+
const lines = [];
|
|
214
|
+
let r;
|
|
215
|
+
try { r = await selftestFromCli(rest, { ...deps, print: (l) => { lines.push(l); print(l); } }); } catch (e) { r = { ok: false, step: "config", message: String(e?.message ?? e) }; }
|
|
216
|
+
if (!r.ok) await reportNightly({ repo: nightlyRepo, io: { gh: async (a) => (deps.io?.gh ?? realGh)(a) }, result: { step: r.step, message: r.message ?? lines.filter((l) => /FAILED|FAIL/.test(l)).join("\n") } });
|
|
217
|
+
return r;
|
|
218
|
+
}
|
|
219
|
+
const gh = deps.io?.gh ?? realGh;
|
|
220
|
+
const st = staticCheck({ root: deps.binRoot ?? join(dirname(CLI)), io: { gh: (a) => gh(a) } });
|
|
221
|
+
print(formatStatic(st));
|
|
222
|
+
let exitCode = st.ok ? 0 : 1, step = st.ok ? null : "static";
|
|
223
|
+
if (argv.includes("--live")) {
|
|
224
|
+
let cfg = {}; try { cfg = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8")) ?? {}; } catch { /* none */ }
|
|
225
|
+
const repo = repoOrNull(flag(argv, "--repo") ?? cfg.selftest?.sandbox_repo);
|
|
226
|
+
if (!repo) throw new Error(`no sandbox repo: pass --repo owner/sandbox or set selftest.sandbox_repo in forge.yaml\n${usage}`);
|
|
227
|
+
const project = cfg.project?.owner && cfg.project?.repo ? `${cfg.project.owner}/${cfg.project.repo}` : null;
|
|
228
|
+
if (project && project.toLowerCase() === repo.toLowerCase()) throw new Error(`refusing to run in ${repo}: it is this project's repo (forge.yaml project), and the harness creates, merges, closes and deletes there; use a disposable sandbox repo`);
|
|
229
|
+
const lang = flag(argv, "--lang") ?? cfg.selftest?.fixture ?? "node";
|
|
230
|
+
const io = deps.io ?? realIo(env, { pool: ["remote", "hybrid"].includes(String(cfg.execution?.mode ?? "")) });
|
|
231
|
+
// The marker binds the head that was tested: read it (from the release repo) before the run, never after.
|
|
232
|
+
const pr = flag(argv, "--pr");
|
|
233
|
+
let releaseRepo = null, head = null;
|
|
234
|
+
if (pr) {
|
|
235
|
+
releaseRepo = flag(argv, "--release-repo") ?? JSON.parse(String(await io.gh(["repo", "view", "--json", "nameWithOwner"]))).nameWithOwner;
|
|
236
|
+
if (releaseRepo.toLowerCase() === repo.toLowerCase()) throw new Error(`refusing to run in ${repo}: it is the release repo`);
|
|
237
|
+
head = String(await io.gh(["pr", "view", String(pr), "-R", releaseRepo, "--json", "headRefOid", "--jq", ".headRefOid"])).trim();
|
|
238
|
+
}
|
|
239
|
+
const r = await runSelftestLive({ repo, lang, io, ...(deps.timing ?? {}) });
|
|
240
|
+
for (const n of r.notes) print(n);
|
|
241
|
+
for (const e of r.cleanupErrors) print(`cleanup: ${e}`);
|
|
242
|
+
if (r.ok) print(`selftest --live: all steps passed on ${repo}`);
|
|
243
|
+
else { print(`selftest --live FAILED at step ${r.step}: ${r.message}`); exitCode = 1; step = r.step; }
|
|
244
|
+
if (pr) await postMarker((a) => io.gh(a), releaseRepo, pr, { result: exitCode === 0 ? "pass" : "fail", sha: head, step });
|
|
245
|
+
if (!r.ok) return { ok: false, step, message: r.message };
|
|
246
|
+
}
|
|
247
|
+
return { ok: exitCode === 0, step };
|
|
248
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { runSelftestLive, stripState, selftestFromCli, STEPS } from "./selftest-live.mjs";
|
|
9
|
+
|
|
10
|
+
/** A fake sandbox world. `d` toggles a defect: add4859 (--add skips), wipe4860 (body wiped), double (resume relaunches), orderBA, noRemoteAuth. */
|
|
11
|
+
function world(d = {}) {
|
|
12
|
+
const w = { issues: {}, closed: [], calls: [], killed: 0, pid: 100, results: {}, order: [], deps: {}, running: {}, clock: 0, pool: !!d.pool };
|
|
13
|
+
const gh = async (a) => {
|
|
14
|
+
w.calls.push(a.join(" "));
|
|
15
|
+
if (a[0] === "api" && a[1] === "-X" && a[2] === "PUT") return "{}";
|
|
16
|
+
if (a[0] === "issue" && a[1] === "create") {
|
|
17
|
+
const n = 10 + Object.keys(w.issues).length; w.issues[n] = { body: a[a.indexOf("--body") + 1], state: "OPEN", labels: [] }; return `https://github.com/o/r/issues/${n}\n`;
|
|
18
|
+
}
|
|
19
|
+
if (a[0] === "issue" && a[1] === "view") {
|
|
20
|
+
const n = a[2], i = w.issues[n];
|
|
21
|
+
if (a.includes("body")) return JSON.stringify({ body: d.wipe4860 && w.wiped ? "<!-- FORGE:STATE {} -->" : `${i.body}\n<!-- FORGE:STATE {"v":1} -->` });
|
|
22
|
+
return JSON.stringify({ state: i.state, labels: i.labels.map((name) => ({ name })) });
|
|
23
|
+
}
|
|
24
|
+
if (a[0] === "issue" && a[1] === "close") { w.closed.push(a[2]); return ""; }
|
|
25
|
+
if (a[0] === "pr" && a[1] === "view") return JSON.stringify({ state: "MERGED", mergedAt: `2026-01-01T00:0${a[2] === "101" ? (d.orderBA ? 2 : 1) : (d.orderBA ? 1 : 2)}:00Z` });
|
|
26
|
+
if (a[0] === "pr" && a[1] === "list") return "";
|
|
27
|
+
if (a[0] === "api") { throw new Error("fail cleanup api"); }
|
|
28
|
+
throw new Error(`unexpected gh ${a.join(" ")}`);
|
|
29
|
+
};
|
|
30
|
+
const io = {
|
|
31
|
+
now: () => (w.clock += 1000), sleep: async () => { w.tick = (w.tick ?? 0) + 1; if (d.wipe4860 && w.tick >= 1) w.wiped = true; finish(); },
|
|
32
|
+
gh, cliVersion: async () => "1.39.0",
|
|
33
|
+
cli: async (a) => {
|
|
34
|
+
if (a[1] === "--add") { const n = Number(a[3]); if (d.add4859) return { code: 0, stdout: `#${n} skipped: unreadable`, stderr: "" }; w.order.push(n); w.deps[n] = [Number(Object.keys(w.issues)[0])]; return { code: 0, stdout: "added", stderr: "" }; }
|
|
35
|
+
throw new Error("cli " + a);
|
|
36
|
+
},
|
|
37
|
+
orchestrate: {
|
|
38
|
+
start: async (repo, issues) => { w.order.push(...issues); w.running[issues[0]] = { pid: ++w.pid }; w.a = issues[0]; return { id: "b1", kill: async () => { w.killed++; } }; },
|
|
39
|
+
resume: async (id) => { w.resumedAt = w.tick ?? 0; if (d.double) w.running[w.a] = { pid: ++w.pid }; return { id, kill: async () => { w.killed++; } }; },
|
|
40
|
+
state: async () => ({ order: w.order, deps: w.deps, running: w.running, results: w.results }),
|
|
41
|
+
},
|
|
42
|
+
remote: w.pool ? { run: async ({ issue }) => { w.remoteIssue = issue; return { where: d.local ? "local" : "remote", authenticated: !d.noRemoteAuth, engineVersion: d.oldEngine ? "1.37.0" : "1.39.0" }; } } : null,
|
|
43
|
+
};
|
|
44
|
+
function finish() {
|
|
45
|
+
if (w.resumedAt !== undefined && !w.finished && (w.tick ?? 0) >= w.resumedAt + 2) {
|
|
46
|
+
w.finished = true;
|
|
47
|
+
const [a, b] = Object.keys(w.issues).map(Number);
|
|
48
|
+
w.results = { [a]: { outcome: "merged", pr: 101 }, [b]: { outcome: "merged", pr: 102 } };
|
|
49
|
+
for (const n of [a, b]) w.issues[n].labels.push("workflow:merged"), w.issues[n].state = "CLOSED";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { w, io };
|
|
53
|
+
}
|
|
54
|
+
const run = (d, extra = {}) => { const { w, io } = world(d); return runSelftestLive({ repo: "o/sandbox", io, pollMs: 1, timeoutMs: 60000, ...extra }).then((r) => ({ w, r })); };
|
|
55
|
+
|
|
56
|
+
describe("selftest --live steps (#4865)", () => {
|
|
57
|
+
it("all steps pass and cleanup runs", async () => {
|
|
58
|
+
const { w, r } = await run({});
|
|
59
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
60
|
+
assert.deepEqual(w.closed, ["10", "11"]);
|
|
61
|
+
assert.match(r.notes.join(), /remote: skipped/);
|
|
62
|
+
});
|
|
63
|
+
it("fixtures: B depends on A and the issue bodies are the fixture text", async () => {
|
|
64
|
+
const { w } = await run({});
|
|
65
|
+
assert.match(w.issues[11].body, /\*\*Depends on\*\*: #10/);
|
|
66
|
+
});
|
|
67
|
+
it("the #4859 defect (--add skips the issue) fails the add step", async () => {
|
|
68
|
+
const { r, w } = await run({ add4859: true });
|
|
69
|
+
assert.equal(r.ok, false); assert.equal(r.step, "add"); assert.match(r.message, /skipped #11/);
|
|
70
|
+
assert.deepEqual(w.closed, ["10", "11"], "cleanup ran after the failure");
|
|
71
|
+
});
|
|
72
|
+
it("the #4860 defect (body wiped to FORGE:STATE) fails the body step", async () => {
|
|
73
|
+
const { r } = await run({ wipe4860: true });
|
|
74
|
+
assert.equal(r.ok, false); assert.equal(r.step, "body"); assert.match(r.message, /lost its body text/);
|
|
75
|
+
});
|
|
76
|
+
it("a lane launched twice after --resume fails the resume step", async () => {
|
|
77
|
+
const { r } = await run({ double: true });
|
|
78
|
+
assert.equal(r.step, "resume"); assert.match(r.message, /launched again/);
|
|
79
|
+
});
|
|
80
|
+
it("B merging before A fails the merge step", async () => {
|
|
81
|
+
const { r } = await run({ orderBA: true });
|
|
82
|
+
assert.equal(r.step, "merge"); assert.match(r.message, /B merged before A/);
|
|
83
|
+
});
|
|
84
|
+
it("remote step: stale sandbox engine or no auth fails it; matching passes", async () => {
|
|
85
|
+
assert.equal((await run({ pool: true })).r.ok, true);
|
|
86
|
+
assert.match((await run({ pool: true, oldEngine: true })).r.message, /1\.37\.0 != CLI 1\.39\.0/);
|
|
87
|
+
assert.equal((await run({ pool: true, noRemoteAuth: true })).r.step, "remote");
|
|
88
|
+
assert.match((await run({ pool: true, local: true })).r.message, /not in the pool/);
|
|
89
|
+
const ok = await run({ pool: true });
|
|
90
|
+
assert.equal(ok.w.remoteIssue, 12); assert.deepEqual(ok.w.closed, ["10", "11", "12"]);
|
|
91
|
+
});
|
|
92
|
+
it("unknown fixture language fails at setup", async () => {
|
|
93
|
+
assert.equal((await runSelftestLive({ repo: "o/r", lang: "ruby", io: {} })).step, "setup");
|
|
94
|
+
});
|
|
95
|
+
it("stripState drops only the annotation", () => assert.equal(stripState("hi\n<!-- FORGE:STATE\n{}\n-->"), "hi"));
|
|
96
|
+
it("has the six ordered phases (cleanup runs after them)", () => assert.deepEqual(STEPS.map((s) => s.name), ["create", "add", "body", "resume", "merge", "remote"]));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("selftestFromCli", () => {
|
|
100
|
+
it("requires --live or --static and a sandbox repo", async () => {
|
|
101
|
+
await assert.rejects(selftestFromCli([]), /usage/);
|
|
102
|
+
await assert.rejects(selftestFromCli(["--live"], { cwd: "/nonexistent", print: () => {}, io: { gh: async () => "" } }), /no sandbox repo/);
|
|
103
|
+
});
|
|
104
|
+
it("a failing step is named in the output, returned not-ok, and the marker is posted as fail", async () => {
|
|
105
|
+
const { io } = world({ add4859: true }); const out = [], posted = [];
|
|
106
|
+
const gh = io.gh;
|
|
107
|
+
io.gh = async (a) => {
|
|
108
|
+
if (a[0] === "pr" && a[1] === "view" && a.includes("headRefOid")) return "a".repeat(40);
|
|
109
|
+
if (a[0] === "repo") return JSON.stringify({ nameWithOwner: "o/r" });
|
|
110
|
+
if (a[0] === "pr" && a[1] === "comment") { posted.push(a.at(-1)); return ""; }
|
|
111
|
+
if (a[1] === "--help" || a[2] === "--help") return "JSON FIELDS\n body, number, title\n";
|
|
112
|
+
return gh(a);
|
|
113
|
+
};
|
|
114
|
+
const r = await selftestFromCli(["--live", "--repo", "o/sandbox", "--pr", "9"], { cwd: "/nonexistent", print: (l) => out.push(l), io, timing: { pollMs: 1 } });
|
|
115
|
+
assert.equal(r.ok, false); assert.equal(r.step, "add");
|
|
116
|
+
assert.ok(out.some((l) => /FAILED at step add/.test(l)));
|
|
117
|
+
assert.match(posted[0], /FORGE:SELFTEST result=fail sha=a{40} step=add/);
|
|
118
|
+
});
|
|
119
|
+
it("the PR head is read from the release repo before the run", async () => {
|
|
120
|
+
const { io } = world({}); const order = [];
|
|
121
|
+
const gh = io.gh;
|
|
122
|
+
io.gh = async (a) => {
|
|
123
|
+
if (a[0] === "pr" && a[1] === "view" && a.includes("headRefOid")) { order.push(`head ${a[a.indexOf("-R") + 1]}`); return "b".repeat(40); }
|
|
124
|
+
if (a[0] === "pr" && a[1] === "comment") { order.push("post"); return ""; }
|
|
125
|
+
if (a[1] === "--help" || a[2] === "--help") return "JSON FIELDS\n body, number, title\n";
|
|
126
|
+
if (a[0] === "issue" && a[1] === "create" && !order.includes("run")) order.push("run");
|
|
127
|
+
return gh(a);
|
|
128
|
+
};
|
|
129
|
+
await selftestFromCli(["--live", "--repo", "o/sandbox", "--pr", "9", "--release-repo", "o/rel"], { cwd: "/nonexistent", print: () => {}, io, timing: { pollMs: 1 } });
|
|
130
|
+
assert.deepEqual(order.slice(0, 2), ["head o/rel", "run"]);
|
|
131
|
+
});
|
|
132
|
+
it("refuses the project repo or the release repo as the sandbox", async () => {
|
|
133
|
+
const cwd = tmpDir("st-"); writeFileSync(join(cwd, "forge.yaml"), "project:\n owner: o\n repo: R\n");
|
|
134
|
+
const io = { gh: async (a) => (a[1] === "--help" || a[2] === "--help" ? "JSON FIELDS\n body\n" : "") };
|
|
135
|
+
await assert.rejects(selftestFromCli(["--live", "--repo", "o/r"], { cwd, print: () => {}, io }), /this project's repo/);
|
|
136
|
+
await assert.rejects(selftestFromCli(["--live", "--repo", "o/x", "--pr", "3", "--release-repo", "o/x"], { cwd: "/nonexistent", print: () => {}, io }), /release repo/);
|
|
137
|
+
});
|
|
138
|
+
it("a flag without a value throws instead of being skipped", async () => {
|
|
139
|
+
await assert.rejects(selftestFromCli(["--live", "--repo", "o/s", "--pr"], { cwd: "/nonexistent", print: () => {}, io: { gh: async () => "" } }), /--pr needs a value/);
|
|
140
|
+
await assert.rejects(selftestFromCli(["--live", "--repo", "--pr", "4"], { cwd: "/nonexistent", print: () => {}, io: { gh: async () => "" } }), /--repo needs a value/);
|
|
141
|
+
});
|
|
142
|
+
it("forge.yaml is read per section: another section's mode or sandbox_repo is not picked up", async () => {
|
|
143
|
+
const cwd = tmpDir("st-"); writeFileSync(join(cwd, "forge.yaml"), "selftest:\n fixture: node\nother:\n sandbox_repo: x/y\n");
|
|
144
|
+
await assert.rejects(selftestFromCli(["--live"], { cwd, print: () => {}, io: { gh: async () => "" } }), /no sandbox repo/);
|
|
145
|
+
});
|
|
146
|
+
it("--nightly-report files a failure that happens before the harness starts (no sandbox repo)", async () => {
|
|
147
|
+
const calls = [];
|
|
148
|
+
const gh = async (a) => { calls.push(a); if (a[1] === "list") return "[]"; if (a[1] === "create") return "https://github.com/o/r/issues/5\n"; return a[1] === "--help" || a[2] === "--help" ? "JSON FIELDS\n body\n" : ""; };
|
|
149
|
+
const r = await selftestFromCli(["--live", "--nightly-report", "o/r"], { cwd: "/nonexistent", print: () => {}, io: { gh } });
|
|
150
|
+
assert.equal(r.ok, false); assert.equal(r.step, "config");
|
|
151
|
+
const create = calls.find((a) => a[0] === "issue" && a[1] === "create");
|
|
152
|
+
assert.ok(create && create.includes("o/r")); assert.match(create.join(" "), /step config/);
|
|
153
|
+
});
|
|
154
|
+
});
|