opencode-longrun-harness 1.2.22
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/LICENSE +21 -0
- package/README.md +390 -0
- package/docs/V1.2.20_EVIDENCE.md +114 -0
- package/docs/V1.2.21_EVIDENCE.md +68 -0
- package/docs/V1.2.22_EVIDENCE.md +52 -0
- package/harness/commissioning/README.md +16 -0
- package/harness/commissioning/inspect-copied-run.mjs +25 -0
- package/harness/commissioning/verify-copied-case.mjs +35 -0
- package/harness/plugin/longrun.js +677 -0
- package/harness/src/cli.mjs +40 -0
- package/harness/src/controller.js +1413 -0
- package/harness/src/evidence.mjs +135 -0
- package/harness/src/execution.mjs +217 -0
- package/harness/src/executor.mjs +21 -0
- package/harness/src/install.mjs +435 -0
- package/harness/src/maintenance.mjs +257 -0
- package/harness/src/memory.mjs +472 -0
- package/harness/test/candidates.test.mjs +73 -0
- package/harness/test/checkpoint.test.mjs +65 -0
- package/harness/test/controller.test.mjs +230 -0
- package/harness/test/evidence.test.mjs +57 -0
- package/harness/test/fixtures/durable-host.mjs +27 -0
- package/harness/test/fixtures/example-app-run.json +1375 -0
- package/harness/test/fixtures/notes-budget-exhausted-run.json +2070 -0
- package/harness/test/fixtures/notes-premature-complete-run.json +1496 -0
- package/harness/test/fixtures/notes-recovery-run.json +622 -0
- package/harness/test/fixtures/presets-readout-run.json +825 -0
- package/harness/test/fixtures/routing-worker.mjs +35 -0
- package/harness/test/fixtures/vitest-failed-receipt.json +33 -0
- package/harness/test/helper.mjs +41 -0
- package/harness/test/install.test.mjs +117 -0
- package/harness/test/lifecycle.test.mjs +102 -0
- package/harness/test/maintenance.test.mjs +204 -0
- package/harness/test/memory.test.mjs +145 -0
- package/harness/test/negative-control.test.mjs +91 -0
- package/harness/test/plugin.test.mjs +169 -0
- package/harness/test/recovery-runner.test.mjs +435 -0
- package/harness/test/recovery.test.mjs +68 -0
- package/harness/test/repair-mechanics.test.mjs +122 -0
- package/harness/test/toolbehavior.test.mjs +75 -0
- package/harness/test/v121-commissioning.test.mjs +177 -0
- package/harness/test/v1210-deadline.test.mjs +134 -0
- package/harness/test/v1211-pause.test.mjs +81 -0
- package/harness/test/v1212-maintenance-pause.test.mjs +76 -0
- package/harness/test/v1213-readout.test.mjs +82 -0
- package/harness/test/v1214-durable.test.mjs +121 -0
- package/harness/test/v1215-guidance.test.mjs +57 -0
- package/harness/test/v1216-test-summary.test.mjs +39 -0
- package/harness/test/v1217-discovery.test.mjs +73 -0
- package/harness/test/v1218-completion-review.test.mjs +203 -0
- package/harness/test/v1219-budget-pause.test.mjs +134 -0
- package/harness/test/v122-lifecycle-resolver.test.mjs +218 -0
- package/harness/test/v1220-budget-amendment.test.mjs +343 -0
- package/harness/test/v1221-negative-fixture-anchor.test.mjs +65 -0
- package/harness/test/v1222-default-evidence-class.test.mjs +75 -0
- package/harness/test/v123-plugin-e2e.test.mjs +120 -0
- package/harness/test/v123-receipt-model.test.mjs +185 -0
- package/harness/test/v124-canonical.test.mjs +147 -0
- package/harness/test/v124-installed.test.mjs +48 -0
- package/harness/test/v125-stability.test.mjs +183 -0
- package/harness/test/v126-execution.test.mjs +183 -0
- package/harness/test/v127-reconciliation.test.mjs +139 -0
- package/harness/test/v128-compaction.test.mjs +156 -0
- package/harness/test/v129-routing.test.mjs +165 -0
- package/harness/tools/audit-receipts.mjs +121 -0
- package/harness/tools/recovery-runner.mjs +499 -0
- package/package.json +49 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import * as C from "../src/controller.js";
|
|
8
|
+
|
|
9
|
+
function tmp(prefix = "lrtest") {
|
|
10
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix + "-"));
|
|
11
|
+
return d;
|
|
12
|
+
}
|
|
13
|
+
function gitAvail() {
|
|
14
|
+
const r = spawnSync("git", ["--version"]);
|
|
15
|
+
return r.status === 0;
|
|
16
|
+
}
|
|
17
|
+
function git(dir, args) { return spawnSync("git", ["-C", dir, ...args], { encoding: "utf8" }); }
|
|
18
|
+
|
|
19
|
+
// --- 3: two repos/worktrees and non-git projects do NOT share state (identity keying)
|
|
20
|
+
test("identity: git vs two non-git dirs key differently; git uses toplevel+HEAD", (t) => {
|
|
21
|
+
if (!gitAvail()) { t.skip("no git"); return; }
|
|
22
|
+
const g = tmp("git"); git(g, ["init", "-q"]); git(g, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "--allow-empty", "-m", "init"]);
|
|
23
|
+
const idG = C.projectIdentity(g);
|
|
24
|
+
assert.equal(idG.kind, "git");
|
|
25
|
+
assert.match(idG.id, /^git:/);
|
|
26
|
+
assert.ok(idG.head && idG.head.length === 40, "git identity carries HEAD");
|
|
27
|
+
|
|
28
|
+
const a = tmp("dira"); const b = tmp("dirb");
|
|
29
|
+
const ida = C.projectIdentity(a), idb = C.projectIdentity(b);
|
|
30
|
+
assert.equal(ida.kind, "dir");
|
|
31
|
+
assert.notEqual(ida.id, idb.id, "two non-git dirs must not collapse to one identity");
|
|
32
|
+
// subdir of a git repo maps to the repo root identity, not to itself
|
|
33
|
+
const sub = path.join(g, "pkg"); fs.mkdirSync(sub);
|
|
34
|
+
const idSub = C.projectIdentity(sub);
|
|
35
|
+
assert.equal(idSub.root, C.projectIdentity(g).root, "subdir resolves to repo root identity");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// --- 3: separate key per (identity, runId)
|
|
39
|
+
test("stateKey separates runs and identities", () => {
|
|
40
|
+
const id = { kind: "dir", id: "dir:/x" };
|
|
41
|
+
const k1 = C.stateKey(id, "runA");
|
|
42
|
+
const k2 = C.stateKey(id, "runB");
|
|
43
|
+
const k3 = C.stateKey({ kind: "dir", id: "dir:/y" }, "runA");
|
|
44
|
+
assert.notEqual(k1, k2);
|
|
45
|
+
assert.notEqual(k1, k3);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// --- 5 + 6: loss function integrity
|
|
49
|
+
test("loss: empty required set rejected", () => {
|
|
50
|
+
assert.deepEqual(C.defaultLoss({ criteria: [{ id: "c1", required: false, status: "PASS" }] }), { error: "empty_required_set" });
|
|
51
|
+
});
|
|
52
|
+
test("loss: zero denominator rejected", () => {
|
|
53
|
+
const r = C.defaultLoss({ criteria: [{ id: "c1", required: true, weight: 0, status: "FAIL" }] });
|
|
54
|
+
assert.equal(r.error, "zero_denominator");
|
|
55
|
+
});
|
|
56
|
+
test("loss: unverified required counts as unsatisfied; weighted fraction", () => {
|
|
57
|
+
const c = { criteria: [
|
|
58
|
+
{ id: "a", required: true, weight: 1, status: "PASS" },
|
|
59
|
+
{ id: "b", required: true, weight: 1, status: "FAIL" }, // missing/stale => unsatisfied
|
|
60
|
+
{ id: "c", required: true, weight: 2, status: "STALE" },
|
|
61
|
+
] };
|
|
62
|
+
const r = C.defaultLoss(c);
|
|
63
|
+
assert.equal(r.denom, 4);
|
|
64
|
+
assert.equal(r.num, 3);
|
|
65
|
+
assert.ok(Math.abs(r.loss - 0.75) < 1e-9);
|
|
66
|
+
});
|
|
67
|
+
test("loss: non-finite evaluator value is an error, never a score", () => {
|
|
68
|
+
assert.equal(C.evaluateEvaluatorResult({ value: NaN }).error, "non_finite_or_missing_value");
|
|
69
|
+
assert.equal(C.evaluateEvaluatorResult({ value: Infinity }).error, "non_finite_or_missing_value");
|
|
70
|
+
assert.equal(C.evaluateEvaluatorResult(null).error, "invalid_evaluator_output");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// --- 6 + 15: a reduced soft loss cannot outweigh a failed hard gate / required check
|
|
74
|
+
test("completion: soft loss cannot beat hard gate or required failure", () => {
|
|
75
|
+
const runNoGate = { contract: { lossTarget: 0, criteria: [{ id: "a", required: true, status: "PASS", weight: 1 }], gates: [] }, state: {}, status: "VERIFYING" };
|
|
76
|
+
assert.equal(C.canComplete(runNoGate).complete, true, "all pass + loss met -> complete");
|
|
77
|
+
|
|
78
|
+
const gateFail = structuredClone(runNoGate);
|
|
79
|
+
gateFail.contract.gates = [{ id: "build", required: true, status: "FAIL" }];
|
|
80
|
+
const r1 = C.canComplete(gateFail);
|
|
81
|
+
assert.equal(r1.complete, false);
|
|
82
|
+
assert.equal(r1.reason, "hard_gates_failed");
|
|
83
|
+
|
|
84
|
+
const ctrlFault = structuredClone(runNoGate);
|
|
85
|
+
ctrlFault.faults = ["evaluator_crashed"];
|
|
86
|
+
const r2 = C.canComplete(ctrlFault);
|
|
87
|
+
assert.equal(r2.complete, false);
|
|
88
|
+
assert.equal(r2.reason, "controller_fault");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// --- 4 + 5: receipts; only PASS satisfies; 0 tests cannot pass; console optimism ignored
|
|
92
|
+
test("receipt: exit code decides, not console text", () => {
|
|
93
|
+
const bad = C.makeReceipt({ checkId: "t", command: "npm test", exitCode: 1, output: "ALL TESTS PASSED", testCount: 5, startedAt: 1, finishedAt: 2, sourceFingerprint: "fp1" });
|
|
94
|
+
assert.equal(bad.status, "FAIL");
|
|
95
|
+
assert.ok(!C.statusSatisfies(bad.status));
|
|
96
|
+
});
|
|
97
|
+
test("receipt: zero discovered tests is NOT_RUN (cannot satisfy coverage)", () => {
|
|
98
|
+
const zero = C.makeReceipt({ checkId: "t", command: "jest", exitCode: 0, testCount: 0, requirementKind: "test", startedAt: 1, finishedAt: 2, sourceFingerprint: "fp1" });
|
|
99
|
+
assert.equal(zero.status, "NOT_RUN");
|
|
100
|
+
});
|
|
101
|
+
test("receipt: stale when source fingerprint changed (tracked/untracked/shell edits)", () => {
|
|
102
|
+
const r = C.makeReceipt({ checkId: "t", command: "true", exitCode: 0, testCount: 3, requirementKind: "test", startedAt: 1, finishedAt: 2, sourceFingerprint: "fpA" });
|
|
103
|
+
assert.equal(C.resolveReceiptStatus(r, "fpA"), "PASS");
|
|
104
|
+
assert.equal(C.resolveReceiptStatus(r, "fpB"), "STALE");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// --- 7: source fingerprint catches untracked + deletions and self-excludes controller output
|
|
108
|
+
test("sourceFingerprint: untracked changes hash; deletion changes hash; deletions listed", (t) => {
|
|
109
|
+
if (!gitAvail()) { t.skip("no git"); return; }
|
|
110
|
+
const g = tmp("fp"); git(g, ["init", "-q"]); git(g, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "--allow-empty", "-m", "i"]);
|
|
111
|
+
fs.writeFileSync(path.join(g, "a.js"), "1");
|
|
112
|
+
git(g, ["add", "a.js"]); git(g, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "-m", "add a"]);
|
|
113
|
+
const fp0 = C.sourceFingerprint(g).hash;
|
|
114
|
+
// modify tracked
|
|
115
|
+
fs.writeFileSync(path.join(g, "a.js"), "2");
|
|
116
|
+
const fp1 = C.sourceFingerprint(g).hash;
|
|
117
|
+
assert.notEqual(fp0, fp1, "tracked modification invalidates");
|
|
118
|
+
// add untracked
|
|
119
|
+
fs.writeFileSync(path.join(g, "note.txt"), "untracked");
|
|
120
|
+
const fp2 = C.sourceFingerprint(g).hash;
|
|
121
|
+
assert.notEqual(fp1, fp2, "untracked addition invalidates (git HEAD alone is insufficient)");
|
|
122
|
+
// delete tracked
|
|
123
|
+
fs.rmSync(path.join(g, "a.js"));
|
|
124
|
+
const fp3 = C.sourceFingerprint(g);
|
|
125
|
+
assert.notEqual(fp2, fp3.hash, "deletion invalidates");
|
|
126
|
+
// controller's own receipt/log must NOT change fingerprint (no perpetual self-invalidation)
|
|
127
|
+
fs.mkdirSync(path.join(g, ".longrun"));
|
|
128
|
+
fs.writeFileSync(path.join(g, ".longrun", "events.log"), "x");
|
|
129
|
+
fs.writeFileSync(path.join(g, "RESUME.md"), "y");
|
|
130
|
+
const fp4 = C.sourceFingerprint(g);
|
|
131
|
+
assert.equal(fp4.hash, fp3.hash, "controller logs + RESUME.md are excluded -> evidence does not self-invalidate");
|
|
132
|
+
// now remove RESUME/events but keep deletion -> compare to a copy where only deletion differs
|
|
133
|
+
const g2 = tmp("fp2"); git(g2, ["init", "-q"]); git(g2, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "--allow-empty", "-m", "i"]);
|
|
134
|
+
fs.writeFileSync(path.join(g2, "a.js"), "2"); git(g2, ["add", "a.js"]); git(g2, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "-m", "a"]);
|
|
135
|
+
fs.writeFileSync(path.join(g2, "note.txt"), "untracked");
|
|
136
|
+
fs.rmSync(path.join(g2, "a.js"));
|
|
137
|
+
const g3 = tmp("fp3"); git(g3, ["init", "-q"]); git(g3, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "--allow-empty", "-m", "i"]);
|
|
138
|
+
fs.writeFileSync(path.join(g3, "a.js"), "2"); git(g3, ["add", "a.js"]); git(g3, ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "-m", "a"]);
|
|
139
|
+
fs.writeFileSync(path.join(g3, "note.txt"), "untracked");
|
|
140
|
+
fs.rmSync(path.join(g3, "a.js"));
|
|
141
|
+
fs.mkdirSync(path.join(g3, ".longrun")); fs.writeFileSync(path.join(g3, ".longrun", "events.log"), "x"); fs.writeFileSync(path.join(g3, "RESUME.md"), "y");
|
|
142
|
+
assert.equal(C.sourceFingerprint(g2).hash, C.sourceFingerprint(g3).hash, "self logs/RESUME excluded so evidence does not self-invalidate");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// --- 4: one writer per worktree (lock not double-acquired)
|
|
146
|
+
test("store: writer lock is exclusive and released", () => {
|
|
147
|
+
const dir = tmp("store");
|
|
148
|
+
const s = new C.Store(dir);
|
|
149
|
+
const key = "k1";
|
|
150
|
+
assert.equal(s.tryLock(key, "t1"), true);
|
|
151
|
+
assert.equal(s.tryLock(key, "t2"), false, "second writer rejected");
|
|
152
|
+
s.releaseLock(key);
|
|
153
|
+
assert.equal(s.tryLock(key, "t3"), true, "reacquire after release");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// --- 4: append-only event log dedups duplicate/reordered events (no double-dispatch)
|
|
157
|
+
test("event log: dedup duplicate events; distinct events append", () => {
|
|
158
|
+
const dir = tmp("store");
|
|
159
|
+
const s = new C.Store(dir);
|
|
160
|
+
const key = "k1";
|
|
161
|
+
assert.equal(s.appendEvent(key, { dedupeKey: "e1", type: "x" }), true);
|
|
162
|
+
assert.equal(s.appendEvent(key, { dedupeKey: "e1", type: "x" }), false, "dup ignored");
|
|
163
|
+
assert.equal(s.appendEvent(key, { dedupeKey: "e2", type: "x" }), true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// --- 7 + 8 + 11: scheduler guards
|
|
167
|
+
test("scheduler: idle alone is never authorisation; helper ignored; dup/single-flight/generation", () => {
|
|
168
|
+
const dir = tmp("sched");
|
|
169
|
+
const s = new C.Store(dir);
|
|
170
|
+
const sched = new C.Scheduler(s);
|
|
171
|
+
// AUTO disabled by default
|
|
172
|
+
assert.equal(sched.requestContinuation({ runKey: "r", eventId: "1", sessionID: "s1", generation: 1, state: "IMPLEMENTING", autoEnabled: false, messageAuthorised: true }).reason, "auto_disabled");
|
|
173
|
+
// helper session ignored even if auto on
|
|
174
|
+
assert.equal(sched.requestContinuation({ runKey: "r", eventId: "2", sessionID: "child", isHelper: true, state: "IMPLEMENTING", autoEnabled: true, messageAuthorised: true }).reason, "helper_session");
|
|
175
|
+
// idle-only (not authorised) blocked
|
|
176
|
+
assert.equal(sched.requestContinuation({ runKey: "r", eventId: "3", sessionID: "s1", generation: 1, state: "IMPLEMENTING", autoEnabled: true, messageAuthorised: false }).reason, "idle_not_authorisation");
|
|
177
|
+
// first authorised dispatch passes
|
|
178
|
+
const first = sched.requestContinuation({ runKey: "r", eventId: "4", sessionID: "s1", generation: 1, state: "IMPLEMENTING", autoEnabled: true, messageAuthorised: true });
|
|
179
|
+
assert.equal(first.dispatch, true, "first dispatch");
|
|
180
|
+
// duplicate event (same id) does not double-dispatch
|
|
181
|
+
const dup = sched.requestContinuation({ runKey: "r", eventId: "4", sessionID: "s1", generation: 1, state: "IMPLEMENTING", autoEnabled: true, messageAuthorised: true });
|
|
182
|
+
assert.equal(dup.reason, "duplicate_event");
|
|
183
|
+
// complete, then single-flight clears
|
|
184
|
+
sched.completeDispatch("r");
|
|
185
|
+
const second = sched.requestContinuation({ runKey: "r", eventId: "5", sessionID: "s1", generation: 2, pendingGeneration: 1, state: "IMPLEMENTING", autoEnabled: true, messageAuthorised: true });
|
|
186
|
+
assert.equal(second.reason, "generation_guard", "stale generation blocked");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// --- 4 + 8: resume authorization
|
|
190
|
+
test("resume: assistant prose / repo file text / stray event cannot resume", () => {
|
|
191
|
+
const run = { status: "PAUSED" };
|
|
192
|
+
assert.equal(C.canResume(run, "user_cli").ok, true);
|
|
193
|
+
assert.equal(C.canResume(run, "assistant_text").ok, false);
|
|
194
|
+
assert.equal(C.canResume(run, "repo_file_text").ok, false, "text in a repository file must not resume a cancelled run");
|
|
195
|
+
assert.equal(C.canResume(run, "event").ok, false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// --- 8 + 9: same-failure / no-progress => bounded replan then pause; counters persist
|
|
199
|
+
test("afterEvaluation: 3x same failure -> replan; 5x no-progress -> replan then pause", () => {
|
|
200
|
+
const mk = () => ({ status: "REPAIRING", budget: C.defaultBudget(), state: {} });
|
|
201
|
+
let run = mk();
|
|
202
|
+
let res;
|
|
203
|
+
for (let i = 0; i < 3; i++) res = C.afterEvaluation(run, { progress: true, failureSignature: "boom" });
|
|
204
|
+
assert.equal(res.next, "NEEDS_REPLAN", "3x same failure requires replan");
|
|
205
|
+
run = mk();
|
|
206
|
+
let r2;
|
|
207
|
+
for (let i = 0; i < 5; i++) r2 = C.afterEvaluation(run, { progress: false });
|
|
208
|
+
assert.equal(r2.next, "NEEDS_REPLAN", "5x no-progress -> one bounded replan");
|
|
209
|
+
const r3 = C.afterEvaluation(run, { progress: false });
|
|
210
|
+
assert.equal(r3.next, "PAUSED", "still stalled -> pause (no infinite loop)");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// --- 9: recovery packet bounded + from authoritative records
|
|
214
|
+
test("recovery packet: includes contract refs, remaining ids, budgets; word-capped", () => {
|
|
215
|
+
const run = {
|
|
216
|
+
status: "REPAIRING", contractHash: "abc", evaluatorHash: "def",
|
|
217
|
+
originalRequest: "Build X feature",
|
|
218
|
+
contract: { criteria: [{ id: "c1", required: true, status: "PASS" }, { id: "c2", required: true, status: "FAIL" }] },
|
|
219
|
+
state: { iterations: 7, noProgressStreak: 2, sameFailureStreak: 1, currentSlice: "sliceA", nextAction: "verify c2", verifiedRefs: ["r1"], decisions: ["use existing stack"], relevantPaths: ["src/x"], failedHypotheses: ["h1"] },
|
|
220
|
+
};
|
|
221
|
+
const { packet, words } = C.buildRecoveryPacket(run);
|
|
222
|
+
assert.match(packet, /REMAINING CRITERIA: c2/);
|
|
223
|
+
assert.match(packet, /Build X feature/);
|
|
224
|
+
assert.match(packet, /iters=7/);
|
|
225
|
+
assert.ok(words < 1500);
|
|
226
|
+
// big packet truncates
|
|
227
|
+
run.originalRequest = "word ".repeat(2000);
|
|
228
|
+
const big = C.buildRecoveryPacket(run, 1500);
|
|
229
|
+
assert.ok(big.words <= 1500, "bounded under limit");
|
|
230
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import * as EV from "../src/evidence.mjs";
|
|
4
|
+
|
|
5
|
+
// ---- weak evidence cannot satisfy a stronger required class --------------------------------
|
|
6
|
+
test("weak UNIT evidence cannot satisfy a BROWSER-required criterion", () => {
|
|
7
|
+
const c = { evidenceClass: "BROWSER" };
|
|
8
|
+
const r = EV.classSatisfies("UNIT", c);
|
|
9
|
+
assert.equal(r.satisfied, false);
|
|
10
|
+
assert.match(r.kind, /WEAK_EVIDENCE|WRONG_CLASS/);
|
|
11
|
+
});
|
|
12
|
+
test("strong BROWSER evidence satisfies a BROWSER-required criterion", () => {
|
|
13
|
+
const r = EV.classSatisfies("BROWSER", { evidenceClass: "BROWSER" });
|
|
14
|
+
assert.equal(r.satisfied, true, r.note);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// ---- a visual criterion cannot pass from mesh/object existence ----------------------------
|
|
18
|
+
test("object-existence / mesh / canvas / DOM proxies never satisfy a VISUAL criterion", () => {
|
|
19
|
+
const visual = { visual: true };
|
|
20
|
+
for (const proxy of ["object_exists", "mesh_count", "canvas_nonblank", "dom_exists"]) {
|
|
21
|
+
const r = EV.classSatisfies(proxy, visual);
|
|
22
|
+
assert.equal(r.satisfied, false, `${proxy} must not satisfy visual: ${JSON.stringify(r)}`);
|
|
23
|
+
assert.equal(r.kind, "PROXY_INSUFFICIENT");
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
test("a unit-level STATIC/UNIT pass also fails a VISUAL criterion (needs render class)", () => {
|
|
27
|
+
assert.equal(EV.classSatisfies("UNIT", { visual: true }).satisfied, false);
|
|
28
|
+
assert.equal(EV.classSatisfies("BROWSER", { visual: true }).satisfied, true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ---- a non-visual unit criterion does NOT require vision -----------------------------------
|
|
32
|
+
test("non-visual UNIT criterion is satisfied by a UNIT receipt (no vision forced)", () => {
|
|
33
|
+
const c = { evidenceClass: "UNIT" };
|
|
34
|
+
assert.equal(EV.classSatisfies("UNIT", c).satisfied, true);
|
|
35
|
+
// an over-strong VISION receipt is not WRONG for a UNIT criterion (no forced ceiling), just not
|
|
36
|
+
// the matched class; a UNIT one matches.
|
|
37
|
+
assert.equal(EV.classSatisfies("STATIC", c).satisfied, false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ---- evidence gap stays separate from a plain test failure --------------------------------
|
|
41
|
+
test("evidenceGap reports missing required classes, not the exit status", () => {
|
|
42
|
+
const c = { evidenceClass: "BROWSER", checks: ["ui"] };
|
|
43
|
+
const gapOnly = EV.evidenceGap(c, [{ status: "PASS", checkId: "ui", evidenceClass: "UNIT" }]);
|
|
44
|
+
assert.equal(gapOnly.gap, true, "a UNIT pass leaves a BROWSER gap open");
|
|
45
|
+
assert.deepEqual(gapOnly.missing, ["BROWSER"]);
|
|
46
|
+
const satisfied = EV.evidenceGap(c, [{ status: "PASS", checkId: "ui", evidenceClass: "BROWSER" }]);
|
|
47
|
+
assert.equal(satisfied.gap, false, "browser evidence closes the gap");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ---- rank ordering --------------------------------------------------------------------------
|
|
51
|
+
test("evidence classes are ordered weakest -> strongest", () => {
|
|
52
|
+
assert.ok(EV.rank("STATIC") < EV.rank("UNIT"));
|
|
53
|
+
assert.ok(EV.rank("UNIT") < EV.rank("INTEGRATION"));
|
|
54
|
+
assert.ok(EV.rank("INTEGRATION") < EV.rank("SYSTEM"));
|
|
55
|
+
assert.ok(EV.rank("SYSTEM") < EV.rank("BROWSER"));
|
|
56
|
+
assert.ok(EV.rank("BROWSER") < EV.rank("VISION"));
|
|
57
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Exact owned offline host for destructive crash injection. Never a live app host.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import * as C from '../../src/controller.js';
|
|
5
|
+
import { F } from '../helper.mjs';
|
|
6
|
+
const base = process.argv[2], dir = path.join(base, 'project');
|
|
7
|
+
process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
|
|
8
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../../src/controller.js');
|
|
9
|
+
const hooks = await F('../plugin/longrun.js', { client: null, directory: dir, worktree: dir });
|
|
10
|
+
const context = { sessionID: 'crash-host', directory: dir, worktree: dir };
|
|
11
|
+
const completed = process.argv[3] === 'complete';
|
|
12
|
+
const command = [process.execPath, '-e', completed
|
|
13
|
+
? "require('node:assert/strict').equal(require('node:fs').readFileSync('value.txt','utf8'),'fixture');console.log('actual assertion passed')"
|
|
14
|
+
: `const fs=require('node:fs');fs.writeFileSync(${JSON.stringify(path.join(base, 'child'))},String(process.pid));process.on('SIGTERM',()=>{});setTimeout(()=>{require('node:assert/strict').equal(fs.readFileSync('value.txt','utf8'),'fixture');},30000)`];
|
|
15
|
+
const started = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Offline durable execution crash test',
|
|
16
|
+
criteria: [{ id: 'assertion', checks: ['check'] }], checkCatalogue: { check: { command, timeoutMs: 35000, kind: 'cmd' } },
|
|
17
|
+
candidateBudget: 2, timeBudgetHours: 0.02, deadlineHours: 0.1, autoContinue: false }, context));
|
|
18
|
+
fs.writeFileSync(path.join(base, 'run-id'), started.runId);
|
|
19
|
+
if (completed) {
|
|
20
|
+
// Hold only the final ledger commit; the independently produced real journal is untouched.
|
|
21
|
+
const mutate = C.Store.prototype.mutate;
|
|
22
|
+
C.Store.prototype.mutate = function(key, callback) {
|
|
23
|
+
if (fs.readdirSync(this.keyPath(key)).some(n => /^execution-.*\.json$/.test(n))) return { error: 'STATE_BUSY' };
|
|
24
|
+
return mutate.call(this, key, callback);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
await hooks.tool.longrun_verify.execute({ checkId: 'check', runId: started.runId }, context);
|