tickmarkr 2.5.4 → 2.5.6
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 +3 -1
- package/dist/adapters/registry.js +6 -1
- package/dist/adapters/types.d.ts +3 -0
- package/dist/cli/commands/approve.d.ts +1 -0
- package/dist/cli/commands/approve.js +118 -9
- package/dist/cli/commands/doctor.d.ts +10 -0
- package/dist/cli/commands/doctor.js +44 -29
- package/dist/cli/commands/fleet.js +199 -33
- package/dist/cli/commands/init.js +196 -6
- package/dist/cli/commands/plan.js +20 -4
- package/dist/cli/commands/resume.js +4 -2
- package/dist/cli/commands/run.js +11 -2
- package/dist/cli/commands/verify.js +102 -81
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +3 -1
- package/dist/config/config.d.ts +25 -8
- package/dist/config/config.js +41 -29
- package/dist/config/fleet-overlay.d.ts +3 -9
- package/dist/config/fleet-overlay.js +114 -19
- package/dist/config/fleet-why.d.ts +7 -0
- package/dist/config/fleet-why.js +5 -0
- package/dist/drivers/index.d.ts +15 -1
- package/dist/drivers/index.js +39 -10
- package/dist/drivers/orca.d.ts +119 -10
- package/dist/drivers/orca.js +781 -110
- package/dist/gates/baseline.d.ts +17 -3
- package/dist/gates/baseline.js +63 -15
- package/dist/gates/cache.d.ts +101 -0
- package/dist/gates/cache.js +401 -0
- package/dist/gates/llm.d.ts +3 -0
- package/dist/gates/llm.js +11 -0
- package/dist/gates/review.d.ts +28 -3
- package/dist/gates/review.js +106 -14
- package/dist/gates/run-gates.d.ts +9 -1
- package/dist/gates/run-gates.js +407 -102
- package/dist/gates/test-manifest.d.ts +128 -0
- package/dist/gates/test-manifest.js +463 -0
- package/dist/gates/test-reporter.d.ts +4 -0
- package/dist/gates/test-reporter.js +57 -0
- package/dist/graph/graph.d.ts +2 -0
- package/dist/graph/graph.js +45 -1
- package/dist/route/preference.d.ts +22 -1
- package/dist/route/preference.js +123 -25
- package/dist/route/router.js +31 -6
- package/dist/run/consult.js +5 -4
- package/dist/run/daemon.d.ts +15 -0
- package/dist/run/daemon.js +638 -167
- package/dist/run/execution-budget.d.ts +25 -0
- package/dist/run/execution-budget.js +142 -0
- package/dist/run/git.d.ts +50 -1
- package/dist/run/git.js +131 -12
- package/dist/run/journal.d.ts +16 -2
- package/dist/run/journal.js +115 -25
- package/dist/run/lease.d.ts +58 -0
- package/dist/run/lease.js +310 -0
- package/dist/run/merge.d.ts +2 -0
- package/dist/run/merge.js +91 -3
- package/dist/run/operator-state.d.ts +11 -0
- package/dist/run/operator-state.js +17 -3
- package/dist/run/recovery.d.ts +8 -0
- package/dist/run/recovery.js +25 -0
- package/dist/run/repair-selection.d.ts +12 -0
- package/dist/run/repair-selection.js +56 -0
- package/dist/run/stall.d.ts +6 -1
- package/dist/run/stall.js +60 -3
- package/dist/tui/cockpit/board.d.ts +96 -0
- package/dist/tui/cockpit/board.js +346 -0
- package/dist/tui/cockpit/decision-actions.js +2 -0
- package/dist/tui/cockpit/layout.d.ts +5 -1
- package/dist/tui/cockpit/layout.js +8 -3
- package/dist/tui/cockpit/live-runtime.js +71 -21
- package/dist/tui/cockpit/run-view.d.ts +7 -5
- package/dist/tui/cockpit/run-view.js +12 -11
- package/dist/tui/ink/fleet-app.d.ts +49 -27
- package/dist/tui/ink/fleet-app.js +229 -38
- package/package.json +2 -2
- package/skills/tickmarkr-overseer/SKILL.md +173 -113
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { BaselineFileDuration } from "./baseline.js";
|
|
2
|
+
export declare function isVitestTestCommand(cmd: string, cwd: string): boolean;
|
|
3
|
+
/** One identity for every path this module compares: repo-relative, forward-slash. `vitest list
|
|
4
|
+
* --json` and `TestModule.moduleId` both hand back an absolute filesystem path already resolved
|
|
5
|
+
* through any symlink in it (e.g. macOS's `/var` -> `/private/var`, under which every OS temp dir —
|
|
6
|
+
* and so every test fixture worktree — lives); `cwd` as tickmarkr holds it may not be. Resolving cwd
|
|
7
|
+
* before computing the relative path is what makes the two actually comparable. Manifests built from
|
|
8
|
+
* a selected-test screen are already repo-relative and pass through unchanged. */
|
|
9
|
+
export declare function toManifestPath(file: string, cwd: string): string;
|
|
10
|
+
export interface TestReportCompletion {
|
|
11
|
+
at: number;
|
|
12
|
+
/** R41: `skipped` = the module ran its lifecycle but executed NO test body (every test skipped);
|
|
13
|
+
* it is present in the manifest accounting and never counted as executed success. */
|
|
14
|
+
status: "passed" | "failed" | "skipped";
|
|
15
|
+
/** Failure fingerprints for this file; absent/empty on a passed file. */
|
|
16
|
+
failures?: string[];
|
|
17
|
+
/** Per-module test-body counts the reporter observed (absent on reports from older reporters). */
|
|
18
|
+
tests?: {
|
|
19
|
+
passed: number;
|
|
20
|
+
failed: number;
|
|
21
|
+
skipped: number;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** The runner's own machine report — requested/started/completed are the runner's claims about ITSELF. */
|
|
25
|
+
export interface TestReport {
|
|
26
|
+
nonce: string;
|
|
27
|
+
requested: string[];
|
|
28
|
+
started: Record<string, number>;
|
|
29
|
+
completed: Record<string, TestReportCompletion>;
|
|
30
|
+
/** Files the reporter observed complete MORE than once — `completed`'s object keys cannot show
|
|
31
|
+
* this themselves (a second write silently overwrites the first), so the reporter records the
|
|
32
|
+
* evidence separately before it is lost. */
|
|
33
|
+
duplicateCompletions?: string[];
|
|
34
|
+
/** Written last, once, when the runner reaches its own terminal state. Its absence means the run
|
|
35
|
+
* never certified completion — killed, crashed, or still in flight — and is never a verdict. */
|
|
36
|
+
certificate?: {
|
|
37
|
+
at: number;
|
|
38
|
+
exitCode: number;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Reads and structurally validates the report; a missing or malformed file is `undefined` — never a partial parse. */
|
|
42
|
+
export declare function readTestReport(path: string): TestReport | undefined;
|
|
43
|
+
export type ManifestVerdictKind = "pass" | "infra" | "work" | "fail-closed";
|
|
44
|
+
export interface ManifestVerdict {
|
|
45
|
+
kind: ManifestVerdictKind;
|
|
46
|
+
pass: boolean;
|
|
47
|
+
details: string;
|
|
48
|
+
meta: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The independent validator: given the manifest THIS invocation was asked to prove, its bound nonce
|
|
52
|
+
* and the independently observed process exit code, decide the
|
|
53
|
+
* verdict from the report alone. `killedFile` short-circuits every report-shaped check — a job this
|
|
54
|
+
* module killed for a per-file hang never reaches its report.
|
|
55
|
+
*/
|
|
56
|
+
export declare function verifyManifestReport(opts: {
|
|
57
|
+
manifest: readonly string[];
|
|
58
|
+
nonce: string;
|
|
59
|
+
exitCode: number | undefined;
|
|
60
|
+
report: TestReport | undefined;
|
|
61
|
+
killedFile?: string;
|
|
62
|
+
hangBudgetMs?: number;
|
|
63
|
+
}): ManifestVerdict;
|
|
64
|
+
/** How much longer than its baseline measurement one file may legitimately run before it is a hang. */
|
|
65
|
+
export declare const FILE_HANG_SLACK = 3;
|
|
66
|
+
export declare const DEFAULT_FILE_HANG_BUDGET_MS = 60000;
|
|
67
|
+
export declare function fileHangBudgetMs(file: string, baselineDurations?: readonly BaselineFileDuration[] | null, ceilingMs?: number, longestFile?: BaselineFileDuration | null): number;
|
|
68
|
+
export interface ManifestRunResult {
|
|
69
|
+
exitCode: number | undefined;
|
|
70
|
+
stdout: string;
|
|
71
|
+
stderr: string;
|
|
72
|
+
report: TestReport | undefined;
|
|
73
|
+
killedFile?: string;
|
|
74
|
+
hangBudgetMs?: number;
|
|
75
|
+
/** The child's own pid (its process GROUP id too, since it is spawned detached) — for a caller
|
|
76
|
+
* that wants to prove the group is really gone after a hang kill (`process.kill(-pid, 0)` throws). */
|
|
77
|
+
pid?: number;
|
|
78
|
+
}
|
|
79
|
+
/** Supervise the configured command and poll the runner's atomic lifecycle snapshots. Every
|
|
80
|
+
* timeout kills the detached process group, including descendants holding the output pipes. */
|
|
81
|
+
export declare function runManifestedTest(cmd: string, cwd: string, opts: {
|
|
82
|
+
manifest: readonly string[];
|
|
83
|
+
nonce: string;
|
|
84
|
+
reportPath: string;
|
|
85
|
+
env?: NodeJS.ProcessEnv;
|
|
86
|
+
baselineDurations?: readonly BaselineFileDuration[] | null;
|
|
87
|
+
longestFile?: BaselineFileDuration | null;
|
|
88
|
+
pollMs?: number;
|
|
89
|
+
overallCeilingMs?: number;
|
|
90
|
+
}): Promise<ManifestRunResult>;
|
|
91
|
+
export interface ManifestGateOutcome {
|
|
92
|
+
pass: boolean;
|
|
93
|
+
kind: ManifestVerdictKind;
|
|
94
|
+
details: string;
|
|
95
|
+
classification?: "infra" | "regression";
|
|
96
|
+
meta: Record<string, unknown>;
|
|
97
|
+
exitCode: number;
|
|
98
|
+
reportPath: string;
|
|
99
|
+
}
|
|
100
|
+
export interface DiscoveredManifest {
|
|
101
|
+
files: string[];
|
|
102
|
+
listing: string;
|
|
103
|
+
separator: string;
|
|
104
|
+
listingExit: number | undefined;
|
|
105
|
+
listingStdout: string;
|
|
106
|
+
}
|
|
107
|
+
/** OBS-1044: THE discovery seam — the files the runner would collect under this exact invocation,
|
|
108
|
+
* from its own listing and never from a stdout summary. The gate and the baseline capture share it,
|
|
109
|
+
* so the count a capture records is the count the gate later asks the report to certify. Throws
|
|
110
|
+
* when the runner cannot list: a count that is not the runner's own is not a count. */
|
|
111
|
+
export declare function discoverTestManifest(cmd: string, cwd: string, opts: {
|
|
112
|
+
dir: string;
|
|
113
|
+
nonce: string;
|
|
114
|
+
env: NodeJS.ProcessEnv;
|
|
115
|
+
overallCeilingMs?: number;
|
|
116
|
+
}): Promise<DiscoveredManifest>;
|
|
117
|
+
/** The baseline capture's reading of the same seam: the manifest's file count, or null when the
|
|
118
|
+
* runner cannot list (a null compares nothing — it never manufactures a deficit). The suite is not
|
|
119
|
+
* run here; the capture already ran it once. */
|
|
120
|
+
export declare function manifestFileCount(cmd: string, cwd: string): Promise<number | null>;
|
|
121
|
+
/** One configured runner execution, and its own collection under the same arguments and environment.
|
|
122
|
+
* The installed runner is trusted (R28 add.1 option B); the nonce catches stale artifacts, not forgery. */
|
|
123
|
+
export declare function evaluateManifestedTest(cmd: string, cwd: string, opts: {
|
|
124
|
+
baselineDurations?: readonly BaselineFileDuration[] | null;
|
|
125
|
+
longestFile?: BaselineFileDuration | null;
|
|
126
|
+
overallCeilingMs?: number;
|
|
127
|
+
artifactDir?: string;
|
|
128
|
+
}): Promise<ManifestGateOutcome>;
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
5
|
+
import { TEST_REPORTER_SOURCE } from "./test-reporter.js";
|
|
6
|
+
import { shq } from "../adapters/types.js";
|
|
7
|
+
import { FORK_CAP_ENV, ROUTING_ENV_SEAMS, SUITE_PARENT_ENV, shell, resolvedCapacity, verificationProtocol } from "../run/git.js";
|
|
8
|
+
/**
|
|
9
|
+
* VL-1 (OBS-985 lineage): a test gate's completion must be the runner's OWN report, never a stdout
|
|
10
|
+
* count. `fileCountDeficit` (baseline.ts) reads a summary LINE — a selected screen's smaller count
|
|
11
|
+
* looked identical to a truncated full suite (OBS-985's shipped defect). This module is the
|
|
12
|
+
* independent validator: it names a MANIFEST (the files the runner is expected to collect), binds a
|
|
13
|
+
* report to ONE invocation with a nonce, and trusts nothing the runner did not certify per file.
|
|
14
|
+
*/
|
|
15
|
+
/** Parse the supported simple shell invocation, retaining quoted argument values. Shell programs
|
|
16
|
+
* are deliberately outside this contract: forwarding reporter options to their last command is unsafe. */
|
|
17
|
+
function shellTokens(command) {
|
|
18
|
+
return command.match(/(?:[^\s'"\\&|;<>]+|'[^']*'|"(?:\\.|[^"\\])*"|\\.)+|[&|;<>]+/g) ?? [];
|
|
19
|
+
}
|
|
20
|
+
function words(command) {
|
|
21
|
+
const tokens = shellTokens(command);
|
|
22
|
+
return tokens.map((token) => token.replace(/'([^']*)'|"((?:\\.|[^"\\])*)"|\\(.)/g, (_all, single, double, escaped) => single ?? (double !== undefined ? double.replace(/\\(["\\$`])/g, "$1") : escaped)));
|
|
23
|
+
}
|
|
24
|
+
const namesRunner = (body) => /(?:^|[\s/\\'";&|])vitest(?:\.mjs)?(?=$|[\s'";&|])/.test(body);
|
|
25
|
+
function scriptInvocation(cmd, cwd) {
|
|
26
|
+
const argv = words(cmd);
|
|
27
|
+
const raw = shellTokens(cmd);
|
|
28
|
+
let manager = 0;
|
|
29
|
+
while (/^[A-Za-z_][A-Za-z_0-9]*=/.test(argv[manager] ?? ""))
|
|
30
|
+
manager++;
|
|
31
|
+
if (!["npm", "pnpm", "yarn"].includes(argv[manager]))
|
|
32
|
+
return undefined;
|
|
33
|
+
let offset = manager + (["run", "run-script"].includes(argv[manager + 1]) ? 2 : 1);
|
|
34
|
+
// OBS-1044: `npm run -s test` — the very spelling detectGateCommands synthesizes — carries the
|
|
35
|
+
// manager's own flags before the script name; a seam blind to them routed every default-configured
|
|
36
|
+
// npm repository past the manifest. Skip flags, never a `--` (which ends the manager's arguments).
|
|
37
|
+
while (offset > manager + 1 && /^-(?!-$)/.test(argv[offset] ?? ""))
|
|
38
|
+
offset++;
|
|
39
|
+
const name = ["t", "tst"].includes(argv[offset]) ? "test" : argv[offset];
|
|
40
|
+
try {
|
|
41
|
+
const body = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")).scripts?.[name];
|
|
42
|
+
return typeof body === "string" ? { body, trailing: raw.slice(offset + 1).filter((a) => a !== "--"), environment: raw.slice(0, manager), npm: argv[manager] === "npm" } : undefined;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function isVitestTestCommand(cmd, cwd) {
|
|
49
|
+
return namesRunner(scriptInvocation(cmd, cwd)?.body ?? cmd);
|
|
50
|
+
}
|
|
51
|
+
function runnerInvocation(cmd, cwd) {
|
|
52
|
+
const script = scriptInvocation(cmd, cwd);
|
|
53
|
+
const body = script?.body ?? cmd;
|
|
54
|
+
if ([body, cmd].some((text) => shellTokens(text).some((token) => /^[&|;<>]+$/.test(token)) || /[`\n]|\$\(/.test(text)))
|
|
55
|
+
throw new Error("vitest command is compound or contains unsupported shell syntax");
|
|
56
|
+
const args = words(body);
|
|
57
|
+
const raw = shellTokens(body);
|
|
58
|
+
let i = 0;
|
|
59
|
+
while (/^[A-Za-z_][A-Za-z_0-9]*=/.test(args[i] ?? ""))
|
|
60
|
+
i++;
|
|
61
|
+
const env = [...(script?.environment ?? []), ...raw.slice(0, i)];
|
|
62
|
+
let runner = args[i];
|
|
63
|
+
const prefix = [];
|
|
64
|
+
if (runner === "node") {
|
|
65
|
+
prefix.push(runner);
|
|
66
|
+
runner = args[++i];
|
|
67
|
+
}
|
|
68
|
+
else if (runner === "npx") {
|
|
69
|
+
runner = args[++i];
|
|
70
|
+
}
|
|
71
|
+
if (!runner || !/(?:^|[/\\])vitest(?:\.mjs)?$/.test(runner))
|
|
72
|
+
throw new Error("vitest script body is not a supported direct runner invocation");
|
|
73
|
+
const binary = runner === "vitest" ? join(cwd, "node_modules/.bin/vitest") : isAbsolute(runner) ? runner : join(cwd, runner);
|
|
74
|
+
if (!existsSync(binary))
|
|
75
|
+
throw new Error(`vitest binary does not exist: ${binary}`);
|
|
76
|
+
const forwarded = [...raw.slice(i + 1), ...(script?.trailing ?? [])];
|
|
77
|
+
if (forwarded[0] === "run")
|
|
78
|
+
forwarded.shift();
|
|
79
|
+
const collection = forwarded.filter((a) => a !== "--run" && a !== "--watch" && a !== "--");
|
|
80
|
+
// R41: `--filesOnly` lists FILE SPECIFICATIONS — the unit the reporter certifies (module start/end) —
|
|
81
|
+
// instead of collected test cases, from which the ordinary listing omits every skipped test and so
|
|
82
|
+
// drops a module whose whole body is skipped (describe.skipIf): the runner then reported two files
|
|
83
|
+
// the manifest never expected (T4 236 vs 238, T8 288 vs 290 on run 91). Filters and the environment
|
|
84
|
+
// are forwarded exactly as before; only the unit enumerated changes.
|
|
85
|
+
return {
|
|
86
|
+
listing: [...env, ...prefix.map(shq), shq(binary), "list", ...collection, "--filesOnly", "--json"].join(" "),
|
|
87
|
+
separator: script?.npm && !/(?:^|\s)--(?:\s|$)/.test(cmd) ? " --" : "",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** One identity for every path this module compares: repo-relative, forward-slash. `vitest list
|
|
91
|
+
* --json` and `TestModule.moduleId` both hand back an absolute filesystem path already resolved
|
|
92
|
+
* through any symlink in it (e.g. macOS's `/var` -> `/private/var`, under which every OS temp dir —
|
|
93
|
+
* and so every test fixture worktree — lives); `cwd` as tickmarkr holds it may not be. Resolving cwd
|
|
94
|
+
* before computing the relative path is what makes the two actually comparable. Manifests built from
|
|
95
|
+
* a selected-test screen are already repo-relative and pass through unchanged. */
|
|
96
|
+
export function toManifestPath(file, cwd) {
|
|
97
|
+
if (!isAbsolute(file))
|
|
98
|
+
return file.replace(/^\.\//, "").split(sep).join("/");
|
|
99
|
+
let resolvedCwd = cwd;
|
|
100
|
+
try {
|
|
101
|
+
resolvedCwd = realpathSync(cwd);
|
|
102
|
+
}
|
|
103
|
+
catch { /* cwd unreadable — best effort with the given path */ }
|
|
104
|
+
return relative(resolvedCwd, file).split(sep).join("/");
|
|
105
|
+
}
|
|
106
|
+
function isTestReportShape(v) {
|
|
107
|
+
if (typeof v !== "object" || v === null)
|
|
108
|
+
return false;
|
|
109
|
+
const r = v;
|
|
110
|
+
if (typeof r.nonce !== "string" || !Array.isArray(r.requested) || typeof r.started !== "object" || r.started === null || typeof r.completed !== "object" || r.completed === null)
|
|
111
|
+
return false;
|
|
112
|
+
if (!r.requested.every((f) => typeof f === "string"))
|
|
113
|
+
return false;
|
|
114
|
+
if (Object.values(r.started).some((v) => typeof v !== "number"))
|
|
115
|
+
return false;
|
|
116
|
+
for (const c of Object.values(r.completed)) {
|
|
117
|
+
if (typeof c !== "object" || c === null)
|
|
118
|
+
return false;
|
|
119
|
+
const cc = c;
|
|
120
|
+
if (typeof cc.at !== "number" || (cc.status !== "passed" && cc.status !== "failed" && cc.status !== "skipped"))
|
|
121
|
+
return false;
|
|
122
|
+
if (cc.tests !== undefined) {
|
|
123
|
+
if (typeof cc.tests !== "object" || cc.tests === null)
|
|
124
|
+
return false;
|
|
125
|
+
const t = cc.tests;
|
|
126
|
+
if (!["passed", "failed", "skipped"].every((k) => typeof t[k] === "number"))
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (r.duplicateCompletions !== undefined) {
|
|
131
|
+
if (!Array.isArray(r.duplicateCompletions) || !r.duplicateCompletions.every((f) => typeof f === "string"))
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (r.certificate !== undefined) {
|
|
135
|
+
if (typeof r.certificate !== "object" || r.certificate === null)
|
|
136
|
+
return false;
|
|
137
|
+
const cert = r.certificate;
|
|
138
|
+
if (typeof cert.at !== "number" || typeof cert.exitCode !== "number")
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
/** Reads and structurally validates the report; a missing or malformed file is `undefined` — never a partial parse. */
|
|
144
|
+
export function readTestReport(path) {
|
|
145
|
+
if (!existsSync(path))
|
|
146
|
+
return undefined;
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
149
|
+
return isTestReportShape(parsed) ? parsed : undefined;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The independent validator: given the manifest THIS invocation was asked to prove, its bound nonce
|
|
157
|
+
* and the independently observed process exit code, decide the
|
|
158
|
+
* verdict from the report alone. `killedFile` short-circuits every report-shaped check — a job this
|
|
159
|
+
* module killed for a per-file hang never reaches its report.
|
|
160
|
+
*/
|
|
161
|
+
export function verifyManifestReport(opts) {
|
|
162
|
+
const { manifest, nonce, exitCode, report, killedFile, hangBudgetMs } = opts;
|
|
163
|
+
if (killedFile !== undefined) {
|
|
164
|
+
return {
|
|
165
|
+
kind: "infra",
|
|
166
|
+
pass: false,
|
|
167
|
+
details: `infra hang: "${killedFile}" started and did not complete within its ${hangBudgetMs}ms budget — killed, its process group is gone`,
|
|
168
|
+
meta: { classification: "infra", infra: true, kind: "hang", file: killedFile, hangBudgetMs },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (!report) {
|
|
172
|
+
return {
|
|
173
|
+
kind: "fail-closed",
|
|
174
|
+
pass: false,
|
|
175
|
+
details: `exit ${exitCode ?? "unknown"} with no invocation-bound test report — a gate that trusts the exit code alone is exactly what this gate refuses to be; failing closed naming the missing report`,
|
|
176
|
+
meta: { classification: "infra", infra: true, missingReport: true },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (report.nonce !== nonce) {
|
|
180
|
+
return {
|
|
181
|
+
kind: "fail-closed",
|
|
182
|
+
pass: false,
|
|
183
|
+
details: `test report carries nonce "${report.nonce}" — this invocation's nonce is "${nonce}"; a report from another invocation is never a verdict, failing closed`,
|
|
184
|
+
meta: { classification: "infra", infra: true, staleNonce: report.nonce, expectedNonce: nonce },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const requestedCounts = new Map();
|
|
188
|
+
for (const f of report.requested)
|
|
189
|
+
requestedCounts.set(f, (requestedCounts.get(f) ?? 0) + 1);
|
|
190
|
+
const duplicated = [...requestedCounts.entries()].find(([, n]) => n > 1)?.[0];
|
|
191
|
+
if (duplicated !== undefined) {
|
|
192
|
+
return {
|
|
193
|
+
kind: "fail-closed",
|
|
194
|
+
pass: false,
|
|
195
|
+
details: `test report names "${duplicated}" more than once in its requested set — a file counted twice is not a trustworthy completion record; failing closed`,
|
|
196
|
+
meta: { classification: "infra", infra: true, duplicateFile: duplicated },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (report.duplicateCompletions?.length) {
|
|
200
|
+
const dup = report.duplicateCompletions[0];
|
|
201
|
+
return {
|
|
202
|
+
kind: "fail-closed",
|
|
203
|
+
pass: false,
|
|
204
|
+
details: `"${dup}" completed more than once in this report — a duplicate completion record (evidence the reporter preserved despite the later write overwriting it) is never a verdict; failing closed`,
|
|
205
|
+
meta: { classification: "infra", infra: true, duplicateCompletion: dup },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const uncorroborated = Object.keys(report.completed).find((f) => !(f in report.started) || !report.requested.includes(f));
|
|
209
|
+
if (uncorroborated !== undefined) {
|
|
210
|
+
return {
|
|
211
|
+
kind: "fail-closed",
|
|
212
|
+
pass: false,
|
|
213
|
+
details: `"${uncorroborated}" is marked completed without a matching requested/started record — an unattributed completion is never a verdict; failing closed`,
|
|
214
|
+
meta: { classification: "infra", infra: true, unattributedCompletion: uncorroborated },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (!report.certificate) {
|
|
218
|
+
const pending = manifest.find((f) => f in report.started && !(f in report.completed));
|
|
219
|
+
return {
|
|
220
|
+
kind: "infra",
|
|
221
|
+
pass: false,
|
|
222
|
+
details: pending !== undefined
|
|
223
|
+
? `the job was killed after its last file record but before its terminal certificate — never a verdict; last incomplete file was "${pending}"`
|
|
224
|
+
: `the job was killed after its last file record but before its terminal certificate — never a verdict`,
|
|
225
|
+
meta: { classification: "infra", infra: true, missingCertificate: true, ...(pending !== undefined ? { file: pending } : {}) },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const failedCompletions = Object.entries(report.completed).filter(([, c]) => c.status === "failed");
|
|
229
|
+
const failingFiles = failedCompletions.map(([file]) => file).sort();
|
|
230
|
+
const failures = failedCompletions.flatMap(([, c]) => c.failures?.length ? c.failures : ["<unnamed failure>"]);
|
|
231
|
+
if (failures.length)
|
|
232
|
+
return { kind: "work", pass: false,
|
|
233
|
+
details: `test report names failing fingerprint(s):\n${failures.join("\n")}`,
|
|
234
|
+
meta: { classification: "regression", failingTests: failures, failingFiles, processExit: exitCode } };
|
|
235
|
+
if (exitCode === undefined) {
|
|
236
|
+
return {
|
|
237
|
+
kind: "fail-closed",
|
|
238
|
+
pass: false,
|
|
239
|
+
details: `the process did not terminate with an exit code (likely killed by a signal) though a certificate naming exit ${report.certificate.exitCode} is present — a passing verdict requires a clean process termination; failing closed naming both`,
|
|
240
|
+
meta: { classification: "infra", infra: true, missingProcessExit: true, certificateExit: report.certificate.exitCode },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (report.certificate.exitCode !== exitCode) {
|
|
244
|
+
return {
|
|
245
|
+
kind: "fail-closed",
|
|
246
|
+
pass: false,
|
|
247
|
+
details: `report certificate names exit ${report.certificate.exitCode} but the process exited ${exitCode} — a report that contradicts the exit code is never a verdict; failing closed naming both`,
|
|
248
|
+
meta: { classification: "infra", infra: true, certificateExit: report.certificate.exitCode, processExit: exitCode },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (exitCode !== 0)
|
|
252
|
+
return {
|
|
253
|
+
kind: "fail-closed", pass: false,
|
|
254
|
+
details: `report has no failed tests but the process exited ${exitCode}; failing closed naming the successful report and nonzero exit`,
|
|
255
|
+
meta: { classification: "infra", infra: true, processExit: exitCode },
|
|
256
|
+
};
|
|
257
|
+
const unexpectedRequested = report.requested.find((f) => !manifest.includes(f));
|
|
258
|
+
const unexpectedStarted = Object.keys(report.started).find((f) => !report.requested.includes(f) || !manifest.includes(f));
|
|
259
|
+
if (unexpectedRequested ?? unexpectedStarted) {
|
|
260
|
+
const file = unexpectedRequested ?? unexpectedStarted;
|
|
261
|
+
return {
|
|
262
|
+
kind: "fail-closed", pass: false,
|
|
263
|
+
details: `"${file}" is outside the invocation's requested/start lifecycle — a report must certify exactly this manifest; failing closed`,
|
|
264
|
+
meta: { classification: "infra", infra: true, unexpectedLifecycleFile: file },
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const pending = manifest.find((f) => f in report.started && !(f in report.completed));
|
|
268
|
+
if (pending !== undefined) {
|
|
269
|
+
return {
|
|
270
|
+
kind: "infra",
|
|
271
|
+
pass: false,
|
|
272
|
+
details: `"${pending}" started and never completed — an infra result naming the file`,
|
|
273
|
+
meta: { classification: "infra", infra: true, file: pending, incomplete: true },
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const missing = manifest.find((f) => !(f in report.completed));
|
|
277
|
+
if (missing !== undefined) {
|
|
278
|
+
return {
|
|
279
|
+
kind: "infra",
|
|
280
|
+
pass: false,
|
|
281
|
+
details: `"${missing}" is in the manifest but absent from the report — an infra result naming the file`,
|
|
282
|
+
meta: { classification: "infra", infra: true, file: missing, missingFromReport: true },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
// R41: a skipped whole module is PRESENT (the lifecycle certified it) but is not executed test-body
|
|
286
|
+
// success. The verdict says how many modules executed and names every one that did not; a report
|
|
287
|
+
// in which nothing executed proves nothing about the tree and is never a passing verdict.
|
|
288
|
+
const skippedModules = manifest.filter((f) => report.completed[f]?.status === "skipped").sort();
|
|
289
|
+
const executedModules = manifest.length - skippedModules.length;
|
|
290
|
+
if (executedModules === 0) {
|
|
291
|
+
return {
|
|
292
|
+
kind: "fail-closed",
|
|
293
|
+
pass: false,
|
|
294
|
+
details: `every manifest module was skipped whole (${skippedModules.join(", ")}) — no test body executed, so this report certifies nothing about the tree; failing closed`,
|
|
295
|
+
meta: { classification: "infra", infra: true, noExecutedModules: true, manifestFiles: manifest.length, executedModules, skippedModules },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
kind: "pass",
|
|
300
|
+
pass: true,
|
|
301
|
+
details: `invocation-bound test report succeeded — ${manifest.length} manifest file(s) present exactly once; ${executedModules} executed`
|
|
302
|
+
+ (skippedModules.length ? `, ${skippedModules.length} skipped whole (${skippedModules.join(", ")})` : ""),
|
|
303
|
+
meta: { manifestFiles: manifest.length, executedModules, skippedModules },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/** How much longer than its baseline measurement one file may legitimately run before it is a hang. */
|
|
307
|
+
export const FILE_HANG_SLACK = 3;
|
|
308
|
+
export const DEFAULT_FILE_HANG_BUDGET_MS = 60_000;
|
|
309
|
+
const usable = (n) => n !== undefined && Number.isFinite(n) && n > 0;
|
|
310
|
+
export function fileHangBudgetMs(file, baselineDurations, ceilingMs = DEFAULT_FILE_HANG_BUDGET_MS, longestFile) {
|
|
311
|
+
const ceiling = usable(ceilingMs) ? ceilingMs : DEFAULT_FILE_HANG_BUDGET_MS;
|
|
312
|
+
const timings = baselineDurations?.filter((d) => usable(d.durationMs)) ?? [];
|
|
313
|
+
const longest = Math.max(0, ...timings.map((d) => d.durationMs), usable(longestFile?.durationMs) ? longestFile.durationMs : 0);
|
|
314
|
+
if (!longest)
|
|
315
|
+
return ceiling;
|
|
316
|
+
const known = timings.find((d) => d.file === file)?.durationMs;
|
|
317
|
+
return Math.min(ceiling, known === undefined ? FILE_HANG_SLACK * longest : Math.max(FILE_HANG_SLACK * known, longest));
|
|
318
|
+
}
|
|
319
|
+
/** Supervise the configured command and poll the runner's atomic lifecycle snapshots. Every
|
|
320
|
+
* timeout kills the detached process group, including descendants holding the output pipes. */
|
|
321
|
+
export function runManifestedTest(cmd, cwd, opts) {
|
|
322
|
+
const pollMs = opts.pollMs ?? 20;
|
|
323
|
+
const overallCeilingMs = usable(opts.overallCeilingMs) ? opts.overallCeilingMs : DEFAULT_FILE_HANG_BUDGET_MS;
|
|
324
|
+
const env = { ...(opts.env ?? process.env), TICKMARKR_TEST_REPORT: opts.reportPath, TICKMARKR_TEST_NONCE: opts.nonce };
|
|
325
|
+
const controller = new AbortController();
|
|
326
|
+
let pid;
|
|
327
|
+
let killedFile;
|
|
328
|
+
let hangBudgetMs;
|
|
329
|
+
let poll;
|
|
330
|
+
const checkHang = (atCeiling = false) => {
|
|
331
|
+
const report = readTestReport(opts.reportPath);
|
|
332
|
+
if (!report || report.nonce !== opts.nonce)
|
|
333
|
+
return;
|
|
334
|
+
const now = Date.now();
|
|
335
|
+
for (const file of opts.manifest) {
|
|
336
|
+
const startedAt = report.started[file];
|
|
337
|
+
if (startedAt === undefined || file in report.completed)
|
|
338
|
+
continue;
|
|
339
|
+
const budget = fileHangBudgetMs(file, opts.baselineDurations, overallCeilingMs, opts.longestFile);
|
|
340
|
+
if (now - startedAt >= budget || (atCeiling && budget === overallCeilingMs)) {
|
|
341
|
+
killedFile = file;
|
|
342
|
+
hangBudgetMs = budget;
|
|
343
|
+
if (!atCeiling)
|
|
344
|
+
controller.abort();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
return shell(cmd, cwd, overallCeilingMs, false, {
|
|
350
|
+
env, signal: controller.signal, onTimeout: () => checkHang(true),
|
|
351
|
+
onSpawn: (childPid) => {
|
|
352
|
+
pid = childPid;
|
|
353
|
+
// Neither the per-file clocks nor the command ceiling includes lease queue time.
|
|
354
|
+
clearInterval(poll);
|
|
355
|
+
poll = setInterval(checkHang, pollMs);
|
|
356
|
+
},
|
|
357
|
+
}).then((result) => ({
|
|
358
|
+
exitCode: result.signalExit ? undefined : result.code,
|
|
359
|
+
stdout: result.stdout, stderr: result.stderr,
|
|
360
|
+
report: readTestReport(opts.reportPath), killedFile, hangBudgetMs, pid,
|
|
361
|
+
})).finally(() => { clearInterval(poll); });
|
|
362
|
+
}
|
|
363
|
+
/** The child environment every manifest invocation (listing and run) receives, and the lifecycle
|
|
364
|
+
* protocol it records. R41: the policy is whatever THIS process was launched with (npm reads
|
|
365
|
+
* `npm_config_ignore_scripts` from the environment over every npmrc); the verdict records it so a
|
|
366
|
+
* verdict measured with `pretest` hooks is never compared to one without. */
|
|
367
|
+
function manifestEnvironment(cwd) {
|
|
368
|
+
const env = { ...process.env, PATH: `${join(cwd, "node_modules/.bin")}:${process.env.PATH ?? ""}`,
|
|
369
|
+
[FORK_CAP_ENV]: String(resolvedCapacity().forkCap), [SUITE_PARENT_ENV]: String(process.pid) };
|
|
370
|
+
const verification = verificationProtocol(env, cwd);
|
|
371
|
+
for (const key of ROUTING_ENV_SEAMS)
|
|
372
|
+
delete env[key];
|
|
373
|
+
// A gate can itself be tested under Vitest. Do not give the child the outer worker identity.
|
|
374
|
+
for (const key of Object.keys(env))
|
|
375
|
+
if (["VITEST", "TEST", "VITEST_WORKER_ID", "VITEST_POOL_ID"].includes(key))
|
|
376
|
+
delete env[key];
|
|
377
|
+
return { env, verification };
|
|
378
|
+
}
|
|
379
|
+
/** OBS-1044: THE discovery seam — the files the runner would collect under this exact invocation,
|
|
380
|
+
* from its own listing and never from a stdout summary. The gate and the baseline capture share it,
|
|
381
|
+
* so the count a capture records is the count the gate later asks the report to certify. Throws
|
|
382
|
+
* when the runner cannot list: a count that is not the runner's own is not a count. */
|
|
383
|
+
export async function discoverTestManifest(cmd, cwd, opts) {
|
|
384
|
+
const invocation = runnerInvocation(cmd, cwd);
|
|
385
|
+
const listed = await runManifestedTest(invocation.listing, cwd, {
|
|
386
|
+
manifest: [], nonce: opts.nonce, reportPath: join(opts.dir, `listing-${opts.nonce}.json`), env: opts.env,
|
|
387
|
+
overallCeilingMs: opts.overallCeilingMs ?? DEFAULT_FILE_HANG_BUDGET_MS,
|
|
388
|
+
});
|
|
389
|
+
if (listed.exitCode !== 0)
|
|
390
|
+
throw new Error(`vitest cannot list files (exit ${listed.exitCode ?? "signal"}): ${listed.stderr || listed.stdout}`);
|
|
391
|
+
let files;
|
|
392
|
+
try {
|
|
393
|
+
const rows = JSON.parse(listed.stdout.slice(listed.stdout.indexOf("[")));
|
|
394
|
+
if (!Array.isArray(rows) || !rows.every((r) => typeof r?.file === "string"))
|
|
395
|
+
throw new Error("invalid listing");
|
|
396
|
+
files = [...new Set(rows.map((r) => toManifestPath(r.file, cwd)))].sort();
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
throw new Error(`vitest cannot list files: invalid JSON listing: ${listed.stdout}`);
|
|
400
|
+
}
|
|
401
|
+
if (!files.length)
|
|
402
|
+
throw new Error("vitest cannot list files: empty manifest");
|
|
403
|
+
return { files, listing: invocation.listing, separator: invocation.separator, listingExit: listed.exitCode, listingStdout: listed.stdout };
|
|
404
|
+
}
|
|
405
|
+
/** The baseline capture's reading of the same seam: the manifest's file count, or null when the
|
|
406
|
+
* runner cannot list (a null compares nothing — it never manufactures a deficit). The suite is not
|
|
407
|
+
* run here; the capture already ran it once. */
|
|
408
|
+
export async function manifestFileCount(cmd, cwd) {
|
|
409
|
+
const dir = mkdtempSync(join(tmpdir(), "tickmarkr-test-manifest-"));
|
|
410
|
+
try {
|
|
411
|
+
const { files } = await discoverTestManifest(cmd, cwd, { dir, nonce: randomBytes(16).toString("hex"), env: manifestEnvironment(cwd).env });
|
|
412
|
+
return files.length;
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/** One configured runner execution, and its own collection under the same arguments and environment.
|
|
419
|
+
* The installed runner is trusted (R28 add.1 option B); the nonce catches stale artifacts, not forgery. */
|
|
420
|
+
export async function evaluateManifestedTest(cmd, cwd, opts) {
|
|
421
|
+
const dir = opts.artifactDir ?? mkdtempSync(join(tmpdir(), "tickmarkr-test-report-"));
|
|
422
|
+
const nonce = randomBytes(16).toString("hex");
|
|
423
|
+
const reportPath = join(dir, `test-manifest-report-${nonce}.json`);
|
|
424
|
+
const reporterPath = join(dir, `test-reporter-${nonce}.mjs`);
|
|
425
|
+
let spawnedCommand = cmd;
|
|
426
|
+
let manifestPath;
|
|
427
|
+
const { env, verification } = manifestEnvironment(cwd);
|
|
428
|
+
try {
|
|
429
|
+
const invocation = await discoverTestManifest(cmd, cwd, { dir, nonce, env, overallCeilingMs: opts.overallCeilingMs });
|
|
430
|
+
const files = invocation.files;
|
|
431
|
+
// R41: the EXPECTED manifest is evidence in its own right — persisted beside the report with the
|
|
432
|
+
// exact discovery invocation, so a later reader can tell what this invocation was asked to prove
|
|
433
|
+
// without reconstructing it from the runner's own claims.
|
|
434
|
+
manifestPath = join(dir, `test-manifest-expected-${nonce}.json`);
|
|
435
|
+
writeFileSync(manifestPath, JSON.stringify({
|
|
436
|
+
nonce, verification, listingCommand: invocation.listing, listingExit: invocation.listingExit,
|
|
437
|
+
listingStdoutSha256: createHash("sha256").update(invocation.listingStdout).digest("hex"),
|
|
438
|
+
discoveredAt: Date.now(), files,
|
|
439
|
+
}, null, 2) + "\n");
|
|
440
|
+
writeFileSync(reporterPath, TEST_REPORTER_SOURCE);
|
|
441
|
+
spawnedCommand = `${cmd}${invocation.separator} --reporter=${shq(reporterPath)} --outputFile=${shq(reportPath)}`;
|
|
442
|
+
const invoked = await runManifestedTest(spawnedCommand, cwd, {
|
|
443
|
+
manifest: files, nonce, reportPath, env,
|
|
444
|
+
baselineDurations: opts.baselineDurations?.map((d) => ({ ...d, file: toManifestPath(d.file, cwd) })),
|
|
445
|
+
longestFile: opts.longestFile,
|
|
446
|
+
overallCeilingMs: opts.overallCeilingMs ?? DEFAULT_FILE_HANG_BUDGET_MS,
|
|
447
|
+
pollMs: 20,
|
|
448
|
+
});
|
|
449
|
+
const verdict = verifyManifestReport({ manifest: files, nonce, exitCode: invoked.exitCode,
|
|
450
|
+
report: invoked.report, killedFile: invoked.killedFile, hangBudgetMs: invoked.hangBudgetMs });
|
|
451
|
+
return { pass: verdict.pass, kind: verdict.kind,
|
|
452
|
+
details: verdict.details + (!invoked.report && invoked.stderr ? `\nvitest reporter: ${invoked.stderr}` : ""),
|
|
453
|
+
classification: verdict.meta.classification,
|
|
454
|
+
meta: { ...verdict.meta, nonce, manifest: files, manifestPath, listingCommand: invocation.listing, verification,
|
|
455
|
+
spawnedCommand, processExit: invoked.exitCode, pid: invoked.pid },
|
|
456
|
+
exitCode: invoked.exitCode ?? -1, reportPath };
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
return { pass: false, kind: "infra", classification: "infra", exitCode: -1, reportPath,
|
|
460
|
+
details: error instanceof Error ? error.message : String(error),
|
|
461
|
+
meta: { classification: "infra", infra: true, manifestDiscoveryFailed: true, spawnedCommand, verification, ...(manifestPath ? { manifestPath } : {}) } };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Loaded by the configured Vitest process. Atomic snapshots expose live lifecycle records to
|
|
2
|
+
* the supervisor; only onTestRunEnd emits the terminal record. This is a completeness protocol,
|
|
3
|
+
* not authentication against the installed runner (which the gate already trusts). */
|
|
4
|
+
export declare const TEST_REPORTER_SOURCE: string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Loaded by the configured Vitest process. Atomic snapshots expose live lifecycle records to
|
|
2
|
+
* the supervisor; only onTestRunEnd emits the terminal record. This is a completeness protocol,
|
|
3
|
+
* not authentication against the installed runner (which the gate already trusts). */
|
|
4
|
+
export const TEST_REPORTER_SOURCE = String.raw `
|
|
5
|
+
import { writeFileSync, renameSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { relative, sep } from 'node:path';
|
|
7
|
+
export default class TickmarkrReporter {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.report = { nonce: process.env.TICKMARKR_TEST_NONCE, requested: [], started: {}, completed: {}, duplicateCompletions: [] };
|
|
10
|
+
this.path = process.env.TICKMARKR_TEST_REPORT;
|
|
11
|
+
this.cwd = realpathSync(process.cwd());
|
|
12
|
+
}
|
|
13
|
+
file(module) { return relative(this.cwd, module.moduleId).split(sep).join('/'); }
|
|
14
|
+
save() {
|
|
15
|
+
writeFileSync(this.path + '.tmp', JSON.stringify(this.report));
|
|
16
|
+
renameSync(this.path + '.tmp', this.path);
|
|
17
|
+
}
|
|
18
|
+
onTestRunStart(specifications) {
|
|
19
|
+
this.report.requested = specifications.map(s => this.file(s));
|
|
20
|
+
this.save();
|
|
21
|
+
}
|
|
22
|
+
onTestModuleStart(module) {
|
|
23
|
+
this.report.started[this.file(module)] = Date.now();
|
|
24
|
+
this.save();
|
|
25
|
+
}
|
|
26
|
+
onTestModuleEnd(module) {
|
|
27
|
+
const file = this.file(module);
|
|
28
|
+
const failed = module.state() === 'failed';
|
|
29
|
+
const failures = [];
|
|
30
|
+
// R41: count test bodies by their own state so a module whose every test was skipped (a
|
|
31
|
+
// describe.skipIf gate) is recorded as SKIPPED — present in the lifecycle, but never as
|
|
32
|
+
// executed test-body success. 'passed'/'failed' executed; anything else did not run.
|
|
33
|
+
const tests = { passed: 0, failed: 0, skipped: 0 };
|
|
34
|
+
for (const test of module.children.allTests()) {
|
|
35
|
+
const state = test.result().state;
|
|
36
|
+
if (state === 'failed') {
|
|
37
|
+
tests.failed++;
|
|
38
|
+
if (failed) {
|
|
39
|
+
const errors = test.result().errors || [];
|
|
40
|
+
failures.push(...(errors.length ? errors.map(e => 'FAIL ' + file + ' > ' + test.fullName + ': ' + e.message) : ['FAIL ' + file + ' > ' + test.fullName]));
|
|
41
|
+
}
|
|
42
|
+
} else if (state === 'passed') tests.passed++;
|
|
43
|
+
else tests.skipped++;
|
|
44
|
+
}
|
|
45
|
+
if (failed && !failures.length) failures.push('FAIL ' + file);
|
|
46
|
+
const status = failed ? 'failed' : tests.passed + tests.failed === 0 ? 'skipped' : 'passed';
|
|
47
|
+
if (file in this.report.completed) this.report.duplicateCompletions.push(file);
|
|
48
|
+
this.report.completed[file] = { at: Date.now(), status, failures, tests };
|
|
49
|
+
this.save();
|
|
50
|
+
}
|
|
51
|
+
onTestRunEnd(modules, errors, reason) {
|
|
52
|
+
const failed = reason === 'failed' || errors.length > 0 || Object.values(this.report.completed).some(c => c.status === 'failed');
|
|
53
|
+
this.report.certificate = { at: Date.now(), exitCode: failed ? 1 : 0 };
|
|
54
|
+
this.save();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
`;
|
package/dist/graph/graph.d.ts
CHANGED
|
@@ -33,7 +33,9 @@ export declare function addEvidence(g: RunGraph, id: string, patch: {
|
|
|
33
33
|
artifacts?: string[];
|
|
34
34
|
gateResults?: unknown[];
|
|
35
35
|
}): RunGraph;
|
|
36
|
+
export declare function chainDepth(g: RunGraph): Map<string, number>;
|
|
36
37
|
export declare function readyTasks(g: RunGraph): Task[];
|
|
38
|
+
export declare function dispatchWaves(g: RunGraph, concurrency: number): Map<string, number>;
|
|
37
39
|
export declare function isComplete(g: RunGraph): boolean;
|
|
38
40
|
export declare function isStalled(g: RunGraph): boolean;
|
|
39
41
|
export declare function closureReaches(g: RunGraph, taskId: string, pred: (t: Task) => boolean): boolean;
|