@emseepea/testing 0.0.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -40
- package/package.json +6 -3
- package/semantic/case.mjs +65 -167
- package/semantic/cli.mjs +95 -380
- package/semantic/discover.mjs +20 -0
- package/semantic/material.mjs +102 -34
- package/semantic/provider.mjs +13 -73
- package/semantic/test.d.mts +29 -0
- package/semantic/test.mjs +164 -0
package/semantic/cli.mjs
CHANGED
|
@@ -1,394 +1,109 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
2
|
import { spawn } from "node:child_process";
|
|
4
|
-
import {
|
|
5
|
-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
6
4
|
import { tmpdir } from "node:os";
|
|
7
5
|
import { dirname, join, resolve } from "node:path";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
import { loadSemanticCase, requiredPaths } from "./case.mjs";
|
|
11
|
-
import { collectMcpMaterial, startSemanticServer, stopSemanticServer } from "./material.mjs";
|
|
12
|
-
import { parseJudgeVerdict, runModel } from "./provider.mjs";
|
|
13
|
-
|
|
14
|
-
const options = parseArguments(process.argv.slice(2));
|
|
15
|
-
const outputPath = resolve(options.output ?? "artifacts/llm-eval/evidence.json");
|
|
16
|
-
const providerPath = fileURLToPath(new URL("./provider.mjs", import.meta.url));
|
|
17
|
-
const promptfooPath = join(dirname(fileURLToPath(import.meta.resolve("promptfoo"))), "entrypoint.js");
|
|
18
|
-
const startedAt = new Date().toISOString();
|
|
19
|
-
const evidence = {
|
|
20
|
-
authoritative: options.provider === "claude-ci",
|
|
21
|
-
cases: {},
|
|
22
|
-
errors: [],
|
|
23
|
-
finishedAt: undefined,
|
|
24
|
-
model: "claude-sonnet-4-6",
|
|
25
|
-
provider: options.provider,
|
|
26
|
-
semanticRetries: 0,
|
|
27
|
-
startedAt,
|
|
28
|
-
status: "failed",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
await mkdir(dirname(outputPath), { recursive: true });
|
|
32
|
-
for (const path of options.casePaths) {
|
|
33
|
-
try {
|
|
34
|
-
const result = options.smoke ? await runSmokeCase(path) : await runSemanticCase(path);
|
|
35
|
-
evidence.cases[result.path] = result.evidence;
|
|
36
|
-
} catch (error) {
|
|
37
|
-
evidence.errors.push(error instanceof Error ? error.message : String(error));
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
evidence.finishedAt = new Date().toISOString();
|
|
41
|
-
evidence.status = evidence.errors.length === 0 ? "passed" : "failed";
|
|
42
|
-
await writeFile(outputPath, `${JSON.stringify(evidence, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
43
|
-
|
|
44
|
-
if (evidence.errors.length > 0) {
|
|
45
|
-
process.stderr.write(`Semantic evaluation failed; evidence: ${outputPath}\n`);
|
|
46
|
-
process.exitCode = 1;
|
|
47
|
-
} else {
|
|
48
|
-
process.stdout.write(`Semantic evaluation passed for ${options.casePaths.length} case(s); evidence: ${outputPath}\n`);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function runSemanticCase(path) {
|
|
52
|
-
const testCase = await loadSemanticCase(path);
|
|
53
|
-
const directory = await mkdtemp(join(tmpdir(), "emseepea-semantic-"));
|
|
54
|
-
const configPath = join(directory, "promptfoo.json");
|
|
55
|
-
const promptfooOutput = join(directory, "promptfoo-output.json");
|
|
56
|
-
const providerEvidence = join(directory, "provider.jsonl");
|
|
57
|
-
const config = promptfooConfig(testCase);
|
|
58
|
-
await writeFile(configPath, JSON.stringify(config), { encoding: "utf8", mode: 0o600 });
|
|
59
|
-
try {
|
|
60
|
-
const run = await runProcess(process.execPath, [
|
|
61
|
-
promptfooPath,
|
|
62
|
-
"eval",
|
|
63
|
-
"--config", configPath,
|
|
64
|
-
"--repeat", "3",
|
|
65
|
-
"--max-concurrency", "1",
|
|
66
|
-
"--no-cache",
|
|
67
|
-
"--no-share",
|
|
68
|
-
"--no-write",
|
|
69
|
-
"--no-table",
|
|
70
|
-
"--no-progress-bar",
|
|
71
|
-
"--output", promptfooOutput,
|
|
72
|
-
], {
|
|
73
|
-
cwd: directory,
|
|
74
|
-
env: cleanEnvironment({
|
|
75
|
-
CLAUDE_CODE_OAUTH_TOKEN: options.provider === "claude-ci"
|
|
76
|
-
? process.env.CLAUDE_CODE_OAUTH_TOKEN
|
|
77
|
-
: undefined,
|
|
78
|
-
EMSEEPEA_EVAL_PROVIDER: options.provider,
|
|
79
|
-
EMSEEPEA_EVAL_PROVIDER_EVIDENCE: providerEvidence,
|
|
80
|
-
EMSEEPEA_MODEL_COMMAND: options.modelCommand,
|
|
81
|
-
PROMPTFOO_CONFIG_DIR: directory,
|
|
82
|
-
}),
|
|
83
|
-
timeoutMs: 38 * 60_000,
|
|
84
|
-
});
|
|
85
|
-
if (run.timedOut) throw new Error(`${testCase.description}: Promptfoo timed out`);
|
|
86
|
-
const records = readJsonLines(await readFile(providerEvidence, "utf8"));
|
|
87
|
-
const entries = promptfooEntries(JSON.parse(await readFile(promptfooOutput, "utf8")));
|
|
88
|
-
if (run.code !== 0) {
|
|
89
|
-
if (process.env.EMSEEPEA_DEBUG === "1") process.stderr.write(run.stderr.slice(-4_000));
|
|
90
|
-
throw new Error(`${testCase.description}: Promptfoo exited ${run.code}`);
|
|
91
|
-
}
|
|
92
|
-
verifyProviderRecords(testCase, records);
|
|
93
|
-
if (entries.length !== 3 || entries.some(({ success }) => success !== true)) {
|
|
94
|
-
throw new Error(`${testCase.description}: ${entries.filter(({ success }) => success).length}/${entries.length} trials passed`);
|
|
95
|
-
}
|
|
96
|
-
return {
|
|
97
|
-
path: testCase.path,
|
|
98
|
-
evidence: {
|
|
99
|
-
agentTrials: records.filter(({ role }) => role === "agent"),
|
|
100
|
-
configSha256: createHash("sha256").update(JSON.stringify(config)).digest("hex"),
|
|
101
|
-
judgeVerdicts: records.filter(({ role }) => role === "judge"),
|
|
102
|
-
promptfooTrials: entries.map(({ description, score, success }) => ({ description, score, success })),
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
} finally {
|
|
106
|
-
await rm(directory, { force: true, recursive: true });
|
|
107
|
-
}
|
|
108
|
-
}
|
|
6
|
+
import { discoverTests } from "./discover.mjs";
|
|
7
|
+
import { modelVersion } from "./provider.mjs";
|
|
109
8
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
"<answer>",
|
|
124
|
-
"{{output}}",
|
|
125
|
-
"</answer>",
|
|
126
|
-
"",
|
|
127
|
-
"Return only JSON with this exact shape:",
|
|
128
|
-
'{"pass": true or false, "score": 1 or 0, "reason": "one concise sentence"}',
|
|
129
|
-
].join("\n");
|
|
130
|
-
const judgeAssertion = () => ({
|
|
131
|
-
type: "llm-rubric",
|
|
132
|
-
value: testCase.criteria,
|
|
133
|
-
provider: judge,
|
|
134
|
-
rubricPrompt: rubric,
|
|
135
|
-
});
|
|
136
|
-
return {
|
|
137
|
-
description: testCase.description,
|
|
138
|
-
providers: [{
|
|
139
|
-
id: `file://${providerPath}`,
|
|
140
|
-
config: { maxRetries: 0, role: "agent" },
|
|
141
|
-
}],
|
|
142
|
-
prompts: ["{{question}}"],
|
|
143
|
-
defaultTest: {
|
|
144
|
-
options: {
|
|
145
|
-
timeoutMs: 120_000,
|
|
146
|
-
},
|
|
147
|
-
assert: [
|
|
148
|
-
{
|
|
149
|
-
type: "javascript",
|
|
150
|
-
value: [
|
|
151
|
-
"const actual = context.metadata?.pathEvidence ?? [];",
|
|
152
|
-
"const seen = new Set(actual.map(({ method, target }) => `${method}:${target}`));",
|
|
153
|
-
"const missing = JSON.parse(context.vars.requiredPaths).filter((path) => !seen.has(path));",
|
|
154
|
-
"return missing.length === 0",
|
|
155
|
-
" ? { pass: true, score: 1, reason: 'required MCP path evidence recorded' }",
|
|
156
|
-
" : { pass: false, score: 0, reason: `missing MCP path evidence: ${missing.join(', ')}` };",
|
|
157
|
-
].join("\n"),
|
|
158
|
-
},
|
|
159
|
-
{
|
|
160
|
-
type: "javascript",
|
|
161
|
-
value: [
|
|
162
|
-
"const answer = String(output).toLowerCase();",
|
|
163
|
-
"const missing = JSON.parse(context.vars.criticalFacts)",
|
|
164
|
-
" .filter((fact) => !answer.includes(fact.toLowerCase()));",
|
|
165
|
-
"return missing.length === 0",
|
|
166
|
-
" ? { pass: true, score: 1, reason: 'critical facts present' }",
|
|
167
|
-
" : { pass: false, score: 0, reason: `missing critical facts: ${missing.join(', ')}` };",
|
|
168
|
-
].join("\n"),
|
|
169
|
-
},
|
|
170
|
-
judgeAssertion(),
|
|
171
|
-
judgeAssertion(),
|
|
172
|
-
judgeAssertion(),
|
|
173
|
-
],
|
|
174
|
-
},
|
|
175
|
-
tests: [{
|
|
176
|
-
description: testCase.description,
|
|
177
|
-
vars: {
|
|
178
|
-
casePath: testCase.path,
|
|
179
|
-
criteria: testCase.criteria,
|
|
180
|
-
criticalFacts: JSON.stringify(testCase.criticalFacts),
|
|
181
|
-
question: testCase.question,
|
|
182
|
-
requiredPaths: JSON.stringify(requiredPaths(testCase)),
|
|
183
|
-
},
|
|
184
|
-
}],
|
|
185
|
-
};
|
|
9
|
+
const paths = [];
|
|
10
|
+
let provider = process.env.EMSEEPEA_EVAL_PROVIDER ?? "claude-local";
|
|
11
|
+
let output = "artifacts/llm-eval/evidence.json";
|
|
12
|
+
let modelCommand;
|
|
13
|
+
let smoke = false;
|
|
14
|
+
for (let i = 2; i < process.argv.length; i += 1) {
|
|
15
|
+
const arg = process.argv[i];
|
|
16
|
+
if (arg === "--provider") provider = process.argv[++i];
|
|
17
|
+
else if (arg === "--output") output = process.argv[++i];
|
|
18
|
+
else if (arg === "--model-command") modelCommand = process.argv[++i];
|
|
19
|
+
else if (arg === "--smoke") smoke = true;
|
|
20
|
+
else if (arg.startsWith("--")) throw new Error(`Unknown option: ${arg}`);
|
|
21
|
+
else paths.push(arg);
|
|
186
22
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
role: "judge",
|
|
225
|
-
status: "completed",
|
|
226
|
-
trial: ((trial - 1) * 3) + judgment,
|
|
227
|
-
turnCount: judgeResult.turnCount,
|
|
228
|
-
verdict: parseJudgeVerdict(judgeResult.answer.trim()),
|
|
229
|
-
});
|
|
230
|
-
} finally {
|
|
231
|
-
await rm(judgeDirectory, { force: true, recursive: true });
|
|
23
|
+
if (!["claude-local", "claude-ci"].includes(provider)) throw new Error("Unsupported provider");
|
|
24
|
+
if ((smoke && provider === "claude-ci") || (modelCommand && !smoke)) throw new Error("Custom model commands are smoke-only");
|
|
25
|
+
if (!paths.length) paths.push("eval");
|
|
26
|
+
const files = await discoverTests(paths);
|
|
27
|
+
const directory = await mkdtemp(join(tmpdir(), "emseepea-evidence-"));
|
|
28
|
+
const evidence = { authoritative: provider === "claude-ci", provider, smoke,
|
|
29
|
+
model: "claude-sonnet-4-6", semanticRetries: 0, revision: process.env.GITHUB_SHA,
|
|
30
|
+
status: "failed", cases: {}, errors: [], startedAt: new Date().toISOString() };
|
|
31
|
+
try {
|
|
32
|
+
const client = JSON.parse(await readFile(new URL("../package.json", import.meta.resolve("@modelcontextprotocol/client")), "utf8"));
|
|
33
|
+
if (client.name !== "@modelcontextprotocol/client" || client.version !== "2.0.0") throw new Error("Unexpected MCP client version");
|
|
34
|
+
evidence.dependencies = { mcpClient: client.version, claudeCli: smoke ? "simulated" : await modelVersion() };
|
|
35
|
+
if (provider === "claude-ci" && evidence.dependencies.claudeCli !== "2.1.248") throw new Error("Unexpected Claude CLI version");
|
|
36
|
+
let interrupted = false;
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
const code = await new Promise((resolveCode) => {
|
|
39
|
+
const environment = { ...process.env, EMSEEPEA_EVAL_PROVIDER: provider, EMSEEPEA_EVAL_SMOKE: smoke ? "1" : "0",
|
|
40
|
+
EMSEEPEA_EVIDENCE_DIR: directory, EMSEEPEA_TEST_FILE: file,
|
|
41
|
+
EMSEEPEA_MODEL_COMMAND: modelCommand ? resolve(modelCommand) : "claude" };
|
|
42
|
+
delete environment.NODE_TEST_CONTEXT;
|
|
43
|
+
const child = spawn(process.execPath, ["--test", "--test-concurrency=1", file], {
|
|
44
|
+
stdio: "inherit",
|
|
45
|
+
env: environment,
|
|
46
|
+
detached: process.platform !== "win32",
|
|
47
|
+
});
|
|
48
|
+
let stopped = false;
|
|
49
|
+
const stop = () => {
|
|
50
|
+
if (stopped || !child.pid) return;
|
|
51
|
+
stopped = true;
|
|
52
|
+
try {
|
|
53
|
+
if (process.platform === "win32") child.kill("SIGKILL");
|
|
54
|
+
else process.kill(-child.pid, "SIGKILL");
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code !== "ESRCH") {
|
|
57
|
+
interrupted = true;
|
|
58
|
+
evidence.errors.push("Could not confirm test-process cleanup");
|
|
59
|
+
child.kill("SIGKILL");
|
|
232
60
|
}
|
|
233
61
|
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
},
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function judgePrompt(testCase, answer) {
|
|
257
|
-
return [
|
|
258
|
-
"Judge the meaning of this answer against every criterion below.",
|
|
259
|
-
"A pass requires every criterion. Ignore harmless wording differences.",
|
|
260
|
-
"",
|
|
261
|
-
"Criteria:",
|
|
262
|
-
testCase.criteria,
|
|
263
|
-
"",
|
|
264
|
-
"Answer:",
|
|
265
|
-
"<answer>",
|
|
266
|
-
answer,
|
|
267
|
-
"</answer>",
|
|
268
|
-
"",
|
|
269
|
-
"Return only JSON with this exact shape:",
|
|
270
|
-
'{"pass": true or false, "score": 1 or 0, "reason": "one concise sentence"}',
|
|
271
|
-
].join("\n");
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function verifyProviderRecords(testCase, records) {
|
|
275
|
-
const agents = records.filter(({ role }) => role === "agent");
|
|
276
|
-
const judges = records.filter(({ role }) => role === "judge");
|
|
277
|
-
if (agents.length !== 3 || judges.length !== 9) {
|
|
278
|
-
throw new Error(`${testCase.description}: expected 3 answers and 9 judgments; got ${agents.length} and ${judges.length}`);
|
|
279
|
-
}
|
|
280
|
-
const required = requiredPaths(testCase);
|
|
281
|
-
if (agents.some(({ status, toolCallCount, materialSha256, pathEvidence, turnCount }) => (
|
|
282
|
-
status !== "completed"
|
|
283
|
-
|| toolCallCount !== 0
|
|
284
|
-
|| turnCount !== 1
|
|
285
|
-
|| !/^[a-f0-9]{64}$/.test(materialSha256 ?? "")
|
|
286
|
-
|| !Array.isArray(pathEvidence)
|
|
287
|
-
|| required.some((path) => !pathEvidence.some(({ method, target, requestSha256, responseSha256 }) => (
|
|
288
|
-
`${method}:${target}` === path
|
|
289
|
-
&& /^[a-f0-9]{64}$/.test(requestSha256 ?? "")
|
|
290
|
-
&& /^[a-f0-9]{64}$/.test(responseSha256 ?? "")
|
|
291
|
-
)))
|
|
292
|
-
))) throw new Error(`${testCase.description}: answer evidence is incomplete`);
|
|
293
|
-
if (judges.some(({ status, verdict, turnCount }) => (
|
|
294
|
-
status !== "completed" || turnCount !== 1 || verdict?.pass !== true
|
|
295
|
-
))) throw new Error(`${testCase.description}: a judgment failed`);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function parseArguments(args) {
|
|
299
|
-
const result = { casePaths: [], modelCommand: "claude", output: undefined, provider: "claude-local", smoke: false };
|
|
300
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
301
|
-
const value = args[index];
|
|
302
|
-
if (value === "--provider") result.provider = args[++index];
|
|
303
|
-
else if (value === "--model-command") result.modelCommand = args[++index];
|
|
304
|
-
else if (value === "--output") result.output = args[++index];
|
|
305
|
-
else if (value === "--smoke") result.smoke = true;
|
|
306
|
-
else if (value?.startsWith("--")) throw new Error(`Unknown option: ${value}`);
|
|
307
|
-
else result.casePaths.push(value);
|
|
62
|
+
};
|
|
63
|
+
const cancel = () => { interrupted = true; stop(); };
|
|
64
|
+
const timer = setTimeout(cancel, 38 * 60_000);
|
|
65
|
+
process.once("SIGINT", cancel);
|
|
66
|
+
process.once("SIGTERM", cancel);
|
|
67
|
+
const finish = (code) => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
process.off("SIGINT", cancel);
|
|
70
|
+
process.off("SIGTERM", cancel);
|
|
71
|
+
stop();
|
|
72
|
+
resolveCode(interrupted ? 1 : code);
|
|
73
|
+
};
|
|
74
|
+
child.once("exit", stop);
|
|
75
|
+
child.once("error", () => finish(1));
|
|
76
|
+
child.once("close", (code) => finish(code ?? 1));
|
|
77
|
+
});
|
|
78
|
+
if (code !== 0) evidence.errors.push(`Test file failed: ${file}`);
|
|
79
|
+
if (interrupted) break;
|
|
308
80
|
}
|
|
309
|
-
|
|
310
|
-
|
|
81
|
+
for (const name of await readdir(directory)) {
|
|
82
|
+
const record = JSON.parse(await readFile(join(directory, name), "utf8"));
|
|
83
|
+
evidence.cases[name.replace(/\.json$/, "")] = record;
|
|
311
84
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
const cases = Object.values(evidence.cases).filter((record) => record.file === file);
|
|
87
|
+
if (!cases.length || cases.some((record) => !validRecord(record, evidence.authoritative, smoke))) {
|
|
88
|
+
evidence.errors.push(`Missing or failed qualification: ${file}`);
|
|
89
|
+
}
|
|
315
90
|
}
|
|
316
|
-
|
|
91
|
+
evidence.status = evidence.errors.length ? "failed" : "passed";
|
|
92
|
+
} finally {
|
|
93
|
+
evidence.finishedAt = new Date().toISOString();
|
|
94
|
+
await mkdir(dirname(resolve(output)), { recursive: true });
|
|
95
|
+
await writeFile(output, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600 });
|
|
96
|
+
await rm(directory, { recursive: true, force: true });
|
|
317
97
|
}
|
|
98
|
+
if (evidence.status !== "passed") process.exitCode = 1;
|
|
99
|
+
console.log(`Semantic checks ${evidence.status}; evidence: ${output}`);
|
|
318
100
|
|
|
319
|
-
function
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
PROMPTFOO_CACHE_ENABLED: "false",
|
|
328
|
-
PROMPTFOO_DISABLE_REMOTE_GENERATION: "true",
|
|
329
|
-
PROMPTFOO_DISABLE_REDTEAM_REMOTE_GENERATION: "true",
|
|
330
|
-
PROMPTFOO_DISABLE_SHARING: "true",
|
|
331
|
-
PROMPTFOO_DISABLE_TELEMETRY: "true",
|
|
332
|
-
PROMPTFOO_DISABLE_TEMPLATE_ENV_VARS: "true",
|
|
333
|
-
PROMPTFOO_DISABLE_UPDATE: "true",
|
|
334
|
-
PROMPTFOO_SELF_HOSTED: "true",
|
|
335
|
-
SHELL: process.env.SHELL,
|
|
336
|
-
TMPDIR: process.env.TMPDIR,
|
|
337
|
-
USER: process.env.USER,
|
|
338
|
-
...extra,
|
|
339
|
-
}).filter(([, value]) => value !== undefined));
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function runProcess(command, args, options = {}) {
|
|
343
|
-
const { timeoutMs, ...spawnOptions } = options;
|
|
344
|
-
return new Promise((resolve) => {
|
|
345
|
-
const grouped = process.platform !== "win32";
|
|
346
|
-
const child = spawn(command, args, { detached: grouped, stdio: ["ignore", "pipe", "pipe"], ...spawnOptions });
|
|
347
|
-
let stdout = "";
|
|
348
|
-
let stderr = "";
|
|
349
|
-
let timedOut = false;
|
|
350
|
-
const stop = () => {
|
|
351
|
-
try {
|
|
352
|
-
if (grouped) process.kill(-child.pid, "SIGKILL");
|
|
353
|
-
else child.kill("SIGKILL");
|
|
354
|
-
} catch (error) {
|
|
355
|
-
if (error?.code !== "ESRCH") throw error;
|
|
356
|
-
}
|
|
357
|
-
};
|
|
358
|
-
const interrupted = () => stop();
|
|
359
|
-
process.once("SIGINT", interrupted);
|
|
360
|
-
process.once("SIGTERM", interrupted);
|
|
361
|
-
const timer = setTimeout(() => {
|
|
362
|
-
timedOut = true;
|
|
363
|
-
stop();
|
|
364
|
-
}, timeoutMs);
|
|
365
|
-
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
366
|
-
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
367
|
-
child.once("error", (error) => {
|
|
368
|
-
clearTimeout(timer);
|
|
369
|
-
process.off("SIGINT", interrupted);
|
|
370
|
-
process.off("SIGTERM", interrupted);
|
|
371
|
-
resolve({ code: 1, error: error.message, stderr, stdout, timedOut });
|
|
372
|
-
});
|
|
373
|
-
child.once("close", (code) => {
|
|
374
|
-
clearTimeout(timer);
|
|
375
|
-
process.off("SIGINT", interrupted);
|
|
376
|
-
process.off("SIGTERM", interrupted);
|
|
377
|
-
resolve({ code: code ?? 1, stderr, stdout, timedOut });
|
|
378
|
-
});
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function readJsonLines(source) {
|
|
383
|
-
return source.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function promptfooEntries(value, found = []) {
|
|
387
|
-
if (Array.isArray(value)) {
|
|
388
|
-
if (value.every((entry) => entry && typeof entry === "object" && "success" in entry)) found.push(...value);
|
|
389
|
-
else for (const entry of value) promptfooEntries(entry, found);
|
|
390
|
-
} else if (value && typeof value === "object") {
|
|
391
|
-
for (const entry of Object.values(value)) promptfooEntries(entry, found);
|
|
392
|
-
}
|
|
393
|
-
return found;
|
|
101
|
+
function validRecord(record, authoritative, smoke) {
|
|
102
|
+
if (record.status !== "passed" || record.authoritative !== authoritative || record.smoke !== smoke
|
|
103
|
+
|| record.answerTrials?.length !== 3 || record.judgeVerdicts?.length !== 9) return false;
|
|
104
|
+
if (record.mode !== "tool-selection") return record.mode === "prepared";
|
|
105
|
+
return record.answerTrials.every((trial) => trial.selectionTurnCount === 1
|
|
106
|
+
&& Number.isInteger(trial.toolCallCount) && trial.toolCallCount >= 1 && trial.toolCallCount <= 3
|
|
107
|
+
&& typeof trial.advertisedToolsSha256 === "string" && typeof trial.selectedCallsSha256 === "string"
|
|
108
|
+
&& JSON.stringify(trial.selectedTools) === JSON.stringify(trial.expectedTools));
|
|
394
109
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export async function discoverTests(paths) {
|
|
5
|
+
const files = new Set();
|
|
6
|
+
async function visit(path) {
|
|
7
|
+
const info = await stat(path);
|
|
8
|
+
if (info.isFile()) {
|
|
9
|
+
if (!path.endsWith(".test.mjs")) throw new Error("Expected a .test.mjs file");
|
|
10
|
+
files.add(resolve(path));
|
|
11
|
+
} else if (info.isDirectory()) {
|
|
12
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
13
|
+
if (entry.isDirectory() || (entry.isFile() && entry.name.endsWith(".test.mjs"))) await visit(join(path, entry.name));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for (const path of paths) await visit(resolve(path));
|
|
18
|
+
if (!files.size) throw new Error("No semantic test files found");
|
|
19
|
+
return [...files].sort();
|
|
20
|
+
}
|