@checklabs/core 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ import { basename } from "node:path";
2
+ import type { ComparisonResult, RunReport, TestResult } from "../types";
3
+ import { c, fmtCost, fmtMs, fmtPct } from "./colors";
4
+
5
+ function groupByFile(results: TestResult[]): Map<string, TestResult[]> {
6
+ const byFile = new Map<string, TestResult[]>();
7
+ for (const r of results) {
8
+ const key = basename(r.file);
9
+ (byFile.get(key) ?? byFile.set(key, []).get(key)!).push(r);
10
+ }
11
+ return byFile;
12
+ }
13
+
14
+ function avgLatency(r: TestResult): number {
15
+ return r.latencies.length ? r.latencies.reduce((a, b) => a + b, 0) / r.latencies.length : 0;
16
+ }
17
+
18
+ /** Print a Jest-style run report. Returns the number of not-passed tests. */
19
+ export function printRunReport(report: RunReport): number {
20
+ const { results, summary, agent } = report;
21
+ console.log("");
22
+ console.log(c.bold(c.cyan("CheckAI")));
23
+ console.log(c.dim(`agent: ${agent.name} · model: ${agent.model || "?"} · backend: ${agent.backend}`));
24
+ console.log("");
25
+
26
+ for (const [file, group] of groupByFile(results)) {
27
+ console.log(c.underline(file));
28
+ for (const r of group) {
29
+ if (r.status === "pass") {
30
+ const judged = r.assertions.find((a) => a.score !== undefined);
31
+ const scoreTag = judged ? c.dim(` score ${judged.score!.toFixed(2)}`) : "";
32
+ console.log(` ${c.green("✓")} ${r.name} ${c.dim(`(${fmtMs(avgLatency(r))})`)}${scoreTag}`);
33
+ } else if (r.status === "error") {
34
+ console.log(` ${c.yellow("⚠")} ${r.name} ${c.yellow("(error)")}`);
35
+ if (r.errorMessage) console.log(` ${c.dim(r.errorMessage.split("\n")[0])}`);
36
+ } else {
37
+ console.log(` ${c.red("✗")} ${r.name}`);
38
+ if (r.failure) {
39
+ console.log(` ${c.dim("matcher:")} ${r.failure.matcher}`);
40
+ console.log(` ${c.dim("Expected:")} ${r.failure.expected}`);
41
+ console.log(` ${c.dim("Actual:")} ${r.failure.actual}`);
42
+ }
43
+ }
44
+ }
45
+ console.log("");
46
+ }
47
+
48
+ const rate = `${summary.passed}/${summary.total}`;
49
+ const rateColored =
50
+ summary.total === 0
51
+ ? c.gray(rate)
52
+ : summary.failed + summary.errored === 0
53
+ ? c.green(rate)
54
+ : c.yellow(rate);
55
+ console.log(c.bold("Overall"));
56
+ console.log(` Pass Rate: ${rateColored}`);
57
+ console.log(` Failed: ${summary.failed === 0 ? c.green("0") : c.red(String(summary.failed))}`);
58
+ if (summary.errored > 0) console.log(` Errored: ${c.yellow(String(summary.errored))}`);
59
+ console.log(` Avg Latency: ${fmtMs(summary.avgLatencyMs)}`);
60
+ console.log(` Tokens: ${summary.totalTokens.toLocaleString("en-US")}`);
61
+ console.log(` Estimated Cost: ${fmtCost(summary.totalCostUsd)}`);
62
+ console.log("");
63
+ return summary.failed + summary.errored;
64
+ }
65
+
66
+ const glyph = (s: "pass" | "fail" | "error") =>
67
+ s === "pass" ? c.green("✓") : s === "fail" ? c.red("✗") : c.yellow("⚠");
68
+
69
+ /** Print a side-by-side comparison report. Returns the regression count. */
70
+ export function printComparison(cmp: ComparisonResult): number {
71
+ console.log("");
72
+ console.log(c.bold(c.cyan("CheckAI · Model Comparison")));
73
+ console.log(
74
+ c.dim(cmp.agents.map((a, i) => `V${i + 1}=${a.name} [${a.model || "?"}, ${a.backend}]`).join(" "))
75
+ );
76
+ console.log("");
77
+
78
+ // Per-file grid.
79
+ const byFile = new Map<string, typeof cmp.rows>();
80
+ for (const row of cmp.rows) {
81
+ const key = basename(row.file);
82
+ (byFile.get(key) ?? byFile.set(key, []).get(key)!).push(row);
83
+ }
84
+ const NAME_W = 46;
85
+ for (const [file, group] of byFile) {
86
+ console.log(c.underline(file));
87
+ console.log(c.dim(" " + "".padEnd(NAME_W) + cmp.agents.map((_, i) => `V${i + 1}`.padEnd(8)).join("")));
88
+ for (const row of group) {
89
+ const label =
90
+ row.name.length > NAME_W - 2 ? row.name.slice(0, NAME_W - 3) + "…" : row.name;
91
+ const cells = row.statuses.map((s) => glyph(s) + " ").join("");
92
+ console.log(` ${label.padEnd(NAME_W)}${cells}`);
93
+ }
94
+ console.log("");
95
+ }
96
+
97
+ // Overall per agent.
98
+ console.log(c.bold("Overall"));
99
+ cmp.agents.forEach((a, i) => {
100
+ const s = cmp.summaries[i];
101
+ const rate = `${s.passed}/${s.total}`;
102
+ const colored = s.passed === s.total ? c.green(rate) : c.yellow(rate);
103
+ console.log(` V${i + 1} ${`${a.name} (${a.model || "?"})`.padEnd(34)} Pass ${colored} ${c.dim(`${fmtCost(s.totalCostUsd)} · ${fmtMs(s.avgLatencyMs)}`)}`);
104
+ });
105
+ console.log("");
106
+
107
+ console.log(c.bold("Diff vs baseline " + c.dim(`(${cmp.baseline})`)));
108
+ console.log(` Regressions: ${cmp.regressions.length === 0 ? c.green("0") : c.red(String(cmp.regressions.length))}`);
109
+ console.log(` Improvements: ${cmp.improvements.length > 0 ? c.green(String(cmp.improvements.length)) : "0"}`);
110
+ console.log(` Unchanged: ${cmp.unchanged}`);
111
+ const candLabel = cmp.agents.length > 2 ? ` ${c.dim("(V2 vs baseline)")}` : "";
112
+ console.log(` Cost Difference: ${deltaColor(cmp.costDeltaPct)}${candLabel}`);
113
+ console.log(` Latency Diff: ${deltaColor(cmp.latencyDeltaPct)}${candLabel}`);
114
+ console.log("");
115
+
116
+ if (cmp.regressions.length > 0) {
117
+ console.log(c.red(c.bold(`Regressions (${cmp.regressions.length})`)));
118
+ for (const r of cmp.regressions) {
119
+ console.log(` ${c.red("✗")} ${r.name} ${c.dim(`[${basename(r.file)}]`)}`);
120
+ const f = r.failures.find((x, i) => i > 0 && x);
121
+ if (f) {
122
+ console.log(` ${c.dim("matcher:")} ${f.matcher}`);
123
+ console.log(` ${c.dim("Expected:")} ${f.expected}`);
124
+ console.log(` ${c.dim("Actual:")} ${f.actual}`);
125
+ }
126
+ }
127
+ console.log("");
128
+ }
129
+ if (cmp.improvements.length > 0) {
130
+ console.log(c.green(c.bold(`Improvements (${cmp.improvements.length})`)));
131
+ for (const r of cmp.improvements) {
132
+ console.log(` ${c.green("✓")} ${r.name} ${c.dim(`[${basename(r.file)}]`)}`);
133
+ }
134
+ console.log("");
135
+ }
136
+ const errored = cmp.rows.filter((r) => r.delta === "error");
137
+ if (errored.length > 0) {
138
+ console.log(c.yellow(c.bold(`Errors (not counted as regressions): ${errored.length}`)));
139
+ for (const r of errored) console.log(` ${c.yellow("⚠")} ${r.name} ${c.dim(`[${basename(r.file)}]`)}`);
140
+ console.log("");
141
+ }
142
+ if (cmp.regressions.length === 0) {
143
+ console.log(c.green("No regressions detected. ✓"));
144
+ console.log("");
145
+ }
146
+ return cmp.regressions.length;
147
+ }
148
+
149
+ function deltaColor(pct: number): string {
150
+ const s = fmtPct(pct);
151
+ if (pct > 1) return c.red(s);
152
+ if (pct < -1) return c.green(s);
153
+ return s;
154
+ }
@@ -0,0 +1,189 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import type { ComparisonResult, RunReport, TestResult } from "../types";
4
+ import { fmtCost, fmtMs, fmtPct } from "./colors";
5
+
6
+ const esc = (s: unknown): string =>
7
+ String(s)
8
+ .replace(/&/g, "&amp;")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;")
11
+ .replace(/"/g, "&quot;");
12
+
13
+ const STYLE = `
14
+ :root { --bg:#0d1117; --panel:#161b22; --line:#30363d; --txt:#e6edf3; --muted:#8b949e;
15
+ --green:#3fb950; --red:#f85149; --yellow:#d29922; --cyan:#58a6ff; }
16
+ * { box-sizing: border-box; }
17
+ body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; }
18
+ .wrap { max-width: 1000px; margin: 0 auto; padding: 32px 20px 80px; }
19
+ h1 { font-size: 22px; margin: 0 0 4px; } h1 .tag { color: var(--cyan); }
20
+ .sub { color: var(--muted); margin-bottom: 24px; }
21
+ .cards { display:flex; flex-wrap:wrap; gap:12px; margin-bottom:28px; }
22
+ .card { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:14px 16px; min-width:130px; }
23
+ .card .k { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
24
+ .card .v { font-size:22px; font-weight:600; margin-top:4px; }
25
+ .green{color:var(--green)} .red{color:var(--red)} .yellow{color:var(--yellow)} .muted{color:var(--muted)} .cyan{color:var(--cyan)}
26
+ .file { margin: 20px 0 8px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:6px; }
27
+ .test { background:var(--panel); border:1px solid var(--line); border-radius:8px; margin:8px 0; padding:0; }
28
+ .test > summary { list-style:none; cursor:pointer; padding:10px 14px; display:flex; align-items:center; gap:10px; }
29
+ .test > summary::-webkit-details-marker { display:none; }
30
+ .test .name { flex:1; } .test .meta { color:var(--muted); font-size:12px; }
31
+ .badge { font-weight:700; width:16px; text-align:center; }
32
+ .detail { padding: 4px 14px 14px 40px; color:var(--muted); }
33
+ .detail code { color:var(--txt); background:#0d1117; padding:1px 4px; border-radius:4px; }
34
+ .kv { margin:2px 0; } .kv b { color:var(--muted); font-weight:500; display:inline-block; min-width:84px; }
35
+ table { width:100%; border-collapse:collapse; margin:10px 0 24px; background:var(--panel); border:1px solid var(--line); border-radius:8px; overflow:hidden; }
36
+ th,td { text-align:left; padding:8px 12px; border-bottom:1px solid var(--line); }
37
+ th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; }
38
+ td.c { text-align:center; width:60px; }
39
+ .section-title { font-size:16px; font-weight:600; margin:24px 0 6px; }
40
+ `;
41
+
42
+ function card(k: string, v: string, cls = ""): string {
43
+ return `<div class="card"><div class="k">${esc(k)}</div><div class="v ${cls}">${v}</div></div>`;
44
+ }
45
+
46
+ function statusBadge(s: TestResult["status"] | "pass" | "fail" | "error"): string {
47
+ if (s === "pass") return `<span class="badge green">✓</span>`;
48
+ if (s === "error") return `<span class="badge yellow">⚠</span>`;
49
+ return `<span class="badge red">✗</span>`;
50
+ }
51
+
52
+ function avgLatency(r: TestResult): number {
53
+ return r.latencies.length ? r.latencies.reduce((a, b) => a + b, 0) / r.latencies.length : 0;
54
+ }
55
+
56
+ function renderRun(report: RunReport): string {
57
+ const { results, summary, agent } = report;
58
+ const s = summary;
59
+ const rateCls = s.total === 0 ? "muted" : s.failed + s.errored === 0 ? "green" : "yellow";
60
+ const cards = [
61
+ card("Pass Rate", `${s.passed}/${s.total}`, rateCls),
62
+ card("Failed", String(s.failed), s.failed ? "red" : "green"),
63
+ ...(s.errored ? [card("Errored", String(s.errored), "yellow")] : []),
64
+ card("Avg Latency", fmtMs(s.avgLatencyMs)),
65
+ card("Tokens", s.totalTokens.toLocaleString("en-US")),
66
+ card("Est. Cost", fmtCost(s.totalCostUsd)),
67
+ ].join("");
68
+
69
+ const byFile = new Map<string, TestResult[]>();
70
+ for (const r of results) (byFile.get(basename(r.file)) ?? byFile.set(basename(r.file), []).get(basename(r.file))!).push(r);
71
+
72
+ let body = "";
73
+ for (const [file, group] of byFile) {
74
+ body += `<div class="file">${esc(file)}</div>`;
75
+ for (const r of group) {
76
+ const judged = r.assertions.find((a) => a.score !== undefined);
77
+ const meta =
78
+ r.status === "pass"
79
+ ? `${fmtMs(avgLatency(r))}${judged ? ` · score ${judged.score!.toFixed(2)}` : ""}`
80
+ : r.status.toUpperCase();
81
+ let detail = "";
82
+ if (r.status === "fail" && r.failure) {
83
+ detail = `<div class="kv"><b>matcher</b> <code>${esc(r.failure.matcher)}</code></div>
84
+ <div class="kv"><b>expected</b> ${esc(r.failure.expected)}</div>
85
+ <div class="kv"><b>actual</b> ${esc(r.failure.actual)}</div>`;
86
+ } else if (r.status === "error") {
87
+ detail = `<div class="kv"><b>error</b> ${esc((r.errorMessage ?? "").split("\n")[0])}</div>`;
88
+ } else {
89
+ detail = r.assertions
90
+ .map((a) => `<div class="kv">${statusBadge(a.pass ? "pass" : "fail")} <code>${esc(a.matcher)}</code> — ${esc(a.expected)}</div>`)
91
+ .join("");
92
+ }
93
+ body += `<details class="test"${r.status !== "pass" ? " open" : ""}>
94
+ <summary>${statusBadge(r.status)}<span class="name">${esc(r.name)}</span><span class="meta">${esc(meta)}</span></summary>
95
+ <div class="detail">${detail}</div></details>`;
96
+ }
97
+ }
98
+
99
+ return page(
100
+ `CheckAI Report`,
101
+ `agent: ${esc(agent.name)} · model: ${esc(agent.model || "?")} · backend: ${esc(agent.backend)} · ${esc(report.finishedAt)}`,
102
+ `<div class="cards">${cards}</div>${body}`
103
+ );
104
+ }
105
+
106
+ function renderComparison(cmp: ComparisonResult): string {
107
+ const q = cmp.agents.length > 2 ? " (V2)" : "";
108
+ const cards = [
109
+ card("Baseline", esc(cmp.baseline), "cyan"),
110
+ card("Regressions", String(cmp.regressions.length), cmp.regressions.length ? "red" : "green"),
111
+ card("Improvements", String(cmp.improvements.length), cmp.improvements.length ? "green" : "muted"),
112
+ card("Unchanged", String(cmp.unchanged), "muted"),
113
+ card("Cost Diff" + q, fmtPct(cmp.costDeltaPct), cmp.costDeltaPct > 1 ? "red" : cmp.costDeltaPct < -1 ? "green" : "muted"),
114
+ card("Latency Diff" + q, fmtPct(cmp.latencyDeltaPct), cmp.latencyDeltaPct > 1 ? "red" : cmp.latencyDeltaPct < -1 ? "green" : "muted"),
115
+ ].join("");
116
+
117
+ const head = `<tr><th>Test</th><th>File</th>${cmp.agents
118
+ .map((a, i) => `<th class="c">V${i + 1} ${esc(a.name)}</th>`)
119
+ .join("")}</tr>`;
120
+ const rows = cmp.rows
121
+ .map(
122
+ (row) =>
123
+ `<tr><td>${esc(row.name)}</td><td class="muted">${esc(basename(row.file))}</td>${row.statuses
124
+ .map((s) => `<td class="c">${statusBadge(s)}</td>`)
125
+ .join("")}</tr>`
126
+ )
127
+ .join("");
128
+
129
+ let regr = "";
130
+ if (cmp.regressions.length) {
131
+ regr =
132
+ `<div class="section-title red">Regressions (${cmp.regressions.length})</div>` +
133
+ cmp.regressions
134
+ .map((r) => {
135
+ const f = r.failures.find((x, i) => i > 0 && x);
136
+ return `<details class="test" open><summary>${statusBadge("fail")}<span class="name">${esc(r.name)}</span><span class="meta">${esc(basename(r.file))}</span></summary>
137
+ <div class="detail">${
138
+ f
139
+ ? `<div class="kv"><b>matcher</b> <code>${esc(f.matcher)}</code></div><div class="kv"><b>expected</b> ${esc(f.expected)}</div><div class="kv"><b>actual</b> ${esc(f.actual)}</div>`
140
+ : ""
141
+ }</div></details>`;
142
+ })
143
+ .join("");
144
+ }
145
+ let impr = "";
146
+ if (cmp.improvements.length) {
147
+ impr =
148
+ `<div class="section-title green">Improvements (${cmp.improvements.length})</div>` +
149
+ cmp.improvements
150
+ .map((r) => `<details class="test"><summary>${statusBadge("pass")}<span class="name">${esc(r.name)}</span><span class="meta">${esc(basename(r.file))}</span></summary><div class="detail muted">passes on candidate</div></details>`)
151
+ .join("");
152
+ }
153
+
154
+ let errs = "";
155
+ const erroredRows = cmp.rows.filter((r) => r.delta === "error");
156
+ if (erroredRows.length) {
157
+ errs =
158
+ `<div class="section-title yellow">Errors — not counted as regressions (${erroredRows.length})</div>` +
159
+ erroredRows
160
+ .map(
161
+ (r) =>
162
+ `<details class="test"><summary>${statusBadge("error")}<span class="name">${esc(r.name)}</span><span class="meta">${esc(basename(r.file))}</span></summary><div class="detail muted">errored on a candidate</div></details>`
163
+ )
164
+ .join("");
165
+ }
166
+
167
+ return page(
168
+ `CheckAI Comparison`,
169
+ `${esc(cmp.agents.map((a, i) => `V${i + 1}=${a.name} [${a.model || "?"}]`).join(" · "))} · ${esc(cmp.finishedAt)}`,
170
+ `<div class="cards">${cards}</div><table>${head}${rows}</table>${regr}${impr}${errs}`
171
+ );
172
+ }
173
+
174
+ function page(title: string, sub: string, body: string): string {
175
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
176
+ <meta name="viewport" content="width=device-width, initial-scale=1">
177
+ <title>${esc(title)}</title><style>${STYLE}</style></head>
178
+ <body><div class="wrap"><h1><span class="tag">CheckAI</span> ${esc(title.replace("CheckAI ", ""))}</h1>
179
+ <div class="sub">${sub}</div>${body}</div></body></html>`;
180
+ }
181
+
182
+ export function renderHtml(data: RunReport | ComparisonResult): string {
183
+ return data.kind === "run" ? renderRun(data) : renderComparison(data);
184
+ }
185
+
186
+ /** Write a self-contained HTML report to disk. */
187
+ export function writeHtmlReport(path: string, data: RunReport | ComparisonResult): void {
188
+ writeFileSync(path, renderHtml(data), "utf8");
189
+ }
@@ -0,0 +1,4 @@
1
+ export { printRunReport, printComparison } from "./console";
2
+ export { renderJson, writeJsonReport } from "./json";
3
+ export { renderHtml, writeHtmlReport } from "./html";
4
+ export { c, fmtMs, fmtCost, fmtPct } from "./colors";
@@ -0,0 +1,11 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import type { ComparisonResult, RunReport } from "../types";
3
+
4
+ export function renderJson(data: RunReport | ComparisonResult): string {
5
+ return JSON.stringify(data, null, 2);
6
+ }
7
+
8
+ /** Write a machine-readable report to disk (checkai-report.json by default). */
9
+ export function writeJsonReport(path: string, data: RunReport | ComparisonResult): void {
10
+ writeFileSync(path, renderJson(data), "utf8");
11
+ }
@@ -0,0 +1,84 @@
1
+ import type {
2
+ ComparisonResult,
3
+ ComparisonRow,
4
+ SuiteSummary,
5
+ TestResult,
6
+ TestStatus,
7
+ } from "../types";
8
+ import { summarize } from "./runner";
9
+
10
+ export interface AgentRun {
11
+ agent: { name: string; model: string; backend: string };
12
+ results: TestResult[];
13
+ }
14
+
15
+ const keyOf = (r: { name: string; file: string }) => `${r.file}::${r.name}`;
16
+
17
+ function pctDelta(base: number, candidate: number): number {
18
+ if (base === 0) return candidate === 0 ? 0 : 100;
19
+ return ((candidate - base) / base) * 100;
20
+ }
21
+
22
+ /**
23
+ * Compare a baseline agent (first run) against one or more candidates over the
24
+ * same suite. A regression is a test the baseline passed but a candidate failed;
25
+ * an improvement is the reverse. Tests that errored are surfaced separately and
26
+ * never counted as regressions.
27
+ */
28
+ export function buildComparison(
29
+ runs: AgentRun[],
30
+ startedAt: string,
31
+ finishedAt: string
32
+ ): ComparisonResult {
33
+ const summaries: SuiteSummary[] = runs.map((r) => summarize(r.results));
34
+ const baseline = runs[0];
35
+ const maps = runs.map((r) => {
36
+ const m = new Map<string, TestResult>();
37
+ for (const res of r.results) m.set(keyOf(res), res);
38
+ return m;
39
+ });
40
+
41
+ const rows: ComparisonRow[] = baseline.results.map((b) => {
42
+ const key = keyOf(b);
43
+ const statuses: TestStatus[] = maps.map((m) => m.get(key)?.status ?? "error");
44
+ const failures = maps.map((m) => m.get(key)?.failure);
45
+ const base = statuses[0];
46
+ const candidates = statuses.slice(1);
47
+
48
+ // Check regression/improvement BEFORE error, so an error in one candidate
49
+ // can't mask a genuine pass→fail regression in another (multi-agent compare).
50
+ let delta: ComparisonRow["delta"];
51
+ if (candidates.some((s) => base === "pass" && s === "fail")) {
52
+ delta = "regression";
53
+ } else if (candidates.some((s) => base === "fail" && s === "pass")) {
54
+ delta = "improvement";
55
+ } else if (statuses.some((s) => s === "error")) {
56
+ delta = "error";
57
+ } else {
58
+ delta = "unchanged";
59
+ }
60
+ return { name: b.name, file: b.file, statuses, failures, delta };
61
+ });
62
+
63
+ const regressions = rows.filter((r) => r.delta === "regression");
64
+ const improvements = rows.filter((r) => r.delta === "improvement");
65
+ const unchanged = rows.filter((r) => r.delta === "unchanged").length;
66
+
67
+ const baseSum = summaries[0];
68
+ const candSum = summaries[1] ?? summaries[0];
69
+
70
+ return {
71
+ kind: "comparison",
72
+ agents: runs.map((r) => r.agent),
73
+ baseline: baseline.agent.name,
74
+ rows,
75
+ summaries,
76
+ regressions,
77
+ improvements,
78
+ unchanged,
79
+ costDeltaPct: pctDelta(baseSum.totalCostUsd, candSum.totalCostUsd),
80
+ latencyDeltaPct: pctDelta(baseSum.avgLatencyMs, candSum.avgLatencyMs),
81
+ startedAt,
82
+ finishedAt,
83
+ };
84
+ }
@@ -0,0 +1,144 @@
1
+ import type {
2
+ AgentAdapter,
3
+ AssertionResult,
4
+ RunReport,
5
+ SuiteSummary,
6
+ TestCase,
7
+ TestResult,
8
+ TokenUsage,
9
+ } from "../types";
10
+ import { CheckAIAssertionError, setAssertionSink, setPendingSink } from "../assertions/expect";
11
+ import { ZERO_USAGE, addUsage } from "../pricing";
12
+
13
+ /** Apply a substring filter to a test list (matches test name or file). */
14
+ export function filterTests(tests: TestCase[], filter?: string): TestCase[] {
15
+ if (!filter) return tests;
16
+ const f = filter.toLowerCase();
17
+ return tests.filter(
18
+ (t) => t.name.toLowerCase().includes(f) || t.file.toLowerCase().includes(f)
19
+ );
20
+ }
21
+
22
+ function nowMs(): number {
23
+ // performance.now() is monotonic, double-precision, in ms — no bigint rounding.
24
+ return performance.now();
25
+ }
26
+
27
+ /** Run a set of tests against a single adapter. Tests run sequentially. */
28
+ export async function runSuite(tests: TestCase[], agent: AgentAdapter): Promise<TestResult[]> {
29
+ const results: TestResult[] = [];
30
+
31
+ for (const tc of tests) {
32
+ const latencies: number[] = [];
33
+ const toolsUsed = new Set<string>();
34
+ let usage: TokenUsage = ZERO_USAGE;
35
+ let costUsd = 0;
36
+
37
+ const instrumented: AgentAdapter = {
38
+ name: agent.name,
39
+ get model() {
40
+ return agent.model;
41
+ },
42
+ run: async (input: string) => {
43
+ const res = await agent.run(input);
44
+ latencies.push(res.latencyMs);
45
+ res.toolsUsed.forEach((t) => toolsUsed.add(t));
46
+ if (res.usage) usage = addUsage(usage, res.usage);
47
+ costUsd += res.costUsd ?? 0;
48
+ return res;
49
+ },
50
+ };
51
+
52
+ const assertions: AssertionResult[] = [];
53
+ const pending: Promise<unknown>[] = [];
54
+ setAssertionSink(assertions);
55
+ setPendingSink(pending);
56
+
57
+ const start = nowMs();
58
+ let status: TestResult["status"] = "pass";
59
+ let failure: AssertionResult | undefined;
60
+ let errorMessage: string | undefined;
61
+
62
+ try {
63
+ await tc.fn({ agent: instrumented });
64
+ } catch (err) {
65
+ if (err instanceof CheckAIAssertionError) {
66
+ status = "fail";
67
+ failure = err.result;
68
+ } else {
69
+ status = "error";
70
+ errorMessage = err instanceof Error ? err.stack ?? err.message : String(err);
71
+ }
72
+ } finally {
73
+ // Wait for any in-flight judge assertions (e.g. an un-awaited
74
+ // toSatisfyBehavior) so they still record while the sink is set.
75
+ await Promise.allSettled(pending);
76
+ setAssertionSink(null);
77
+ setPendingSink(null);
78
+ }
79
+
80
+ // Catch a failing assertion that was recorded by an un-awaited async matcher.
81
+ if (status === "pass") {
82
+ const firstFail = assertions.find((a) => !a.pass);
83
+ if (firstFail) {
84
+ status = "fail";
85
+ failure = firstFail;
86
+ }
87
+ }
88
+
89
+ results.push({
90
+ name: tc.name,
91
+ file: tc.file,
92
+ status,
93
+ assertions,
94
+ failure,
95
+ errorMessage,
96
+ latencies,
97
+ toolsUsed: [...toolsUsed],
98
+ usage,
99
+ costUsd,
100
+ durationMs: nowMs() - start,
101
+ });
102
+ }
103
+
104
+ return results;
105
+ }
106
+
107
+ /** Aggregate stats over a result set. */
108
+ export function summarize(results: TestResult[]): SuiteSummary {
109
+ const total = results.length;
110
+ const passed = results.filter((r) => r.status === "pass").length;
111
+ const failed = results.filter((r) => r.status === "fail").length;
112
+ const errored = results.filter((r) => r.status === "error").length;
113
+ const latencies = results.flatMap((r) => r.latencies);
114
+ const avgLatencyMs =
115
+ latencies.length > 0 ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
116
+ const totalTokens = results.reduce((a, r) => a + r.usage.totalTokens, 0);
117
+ const totalCostUsd = results.reduce((a, r) => a + r.costUsd, 0);
118
+ return {
119
+ total,
120
+ passed,
121
+ failed,
122
+ errored,
123
+ passRate: total > 0 ? passed / total : 0,
124
+ avgLatencyMs,
125
+ totalTokens,
126
+ totalCostUsd,
127
+ };
128
+ }
129
+
130
+ export function buildRunReport(
131
+ agent: { name: string; model: string; backend: string },
132
+ results: TestResult[],
133
+ startedAt: string,
134
+ finishedAt: string
135
+ ): RunReport {
136
+ return {
137
+ kind: "run",
138
+ agent,
139
+ results,
140
+ summary: summarize(results),
141
+ startedAt,
142
+ finishedAt,
143
+ };
144
+ }