@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/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 { createHash } from "node:crypto";
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 { fileURLToPath } from "node:url";
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
- function promptfooConfig(testCase) {
111
- const judge = {
112
- id: `file://${providerPath}`,
113
- config: { maxRetries: 0, role: "judge" },
114
- };
115
- const rubric = [
116
- "Judge the meaning of this answer against every criterion below.",
117
- "A pass requires every criterion. Ignore harmless wording differences.",
118
- "",
119
- "Criteria:",
120
- "{{criteria}}",
121
- "",
122
- "Answer:",
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
- async function runSmokeCase(path) {
189
- const testCase = await loadSemanticCase(path);
190
- const records = [];
191
- const previousModelCommand = process.env.EMSEEPEA_MODEL_COMMAND;
192
- process.env.EMSEEPEA_MODEL_COMMAND = options.modelCommand;
193
- try {
194
- for (let trial = 1; trial <= 3; trial += 1) {
195
- const directory = await mkdtemp(join(tmpdir(), "emseepea-agent-"));
196
- let running;
197
- try {
198
- running = await startSemanticServer(testCase);
199
- const material = await collectMcpMaterial(running.url, testCase);
200
- const prompt = `${material.text}\n\nAnswer only from that MCP material.\n\nQuestion:\n${testCase.question}`;
201
- const result = await runModel(options.provider, prompt, directory);
202
- const agentRecord = {
203
- casePath: testCase.path,
204
- materialSha256: createHash("sha256").update(material.text).digest("hex"),
205
- models: result.models,
206
- pathEvidence: material.pathEvidence,
207
- provider: options.provider,
208
- role: "agent",
209
- status: "completed",
210
- toolCallCount: 0,
211
- trial,
212
- turnCount: result.turnCount,
213
- };
214
- records.push(agentRecord);
215
- const answer = result.answer;
216
- for (let judgment = 1; judgment <= 3; judgment += 1) {
217
- const judgeDirectory = await mkdtemp(join(tmpdir(), "emseepea-judge-"));
218
- try {
219
- const judgeResult = await runModel(options.provider, judgePrompt(testCase, answer), judgeDirectory);
220
- records.push({
221
- casePath: testCase.path,
222
- models: judgeResult.models,
223
- provider: options.provider,
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
- } finally {
235
- if (running) await stopSemanticServer(running.child);
236
- await rm(directory, { force: true, recursive: true });
237
- }
238
- }
239
- } finally {
240
- if (previousModelCommand === undefined) delete process.env.EMSEEPEA_MODEL_COMMAND;
241
- else process.env.EMSEEPEA_MODEL_COMMAND = previousModelCommand;
242
- }
243
- verifyProviderRecords(testCase, records);
244
- return {
245
- path: testCase.path,
246
- evidence: {
247
- agentTrials: records.filter(({ role }) => role === "agent"),
248
- configSha256: createHash("sha256").update(JSON.stringify(promptfooConfig(testCase))).digest("hex"),
249
- judgeVerdicts: records.filter(({ role }) => role === "judge"),
250
- promptfooTrials: [{ description: testCase.description, score: 1, success: true }],
251
- smoke: true,
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
- if (!new Set(["claude-local", "claude-ci"]).has(result.provider)) {
310
- throw new Error(`Unsupported provider: ${result.provider}`);
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
- if (result.casePaths.length === 0) throw new Error("Pass at least one eval.yaml path");
313
- if (result.modelCommand.includes("/") || result.modelCommand.includes("\\")) {
314
- result.modelCommand = resolve(result.modelCommand);
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
- return result;
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 cleanEnvironment(extra) {
320
- return Object.fromEntries(Object.entries({
321
- CI: "true",
322
- HOME: process.env.HOME,
323
- LANG: process.env.LANG ?? "C.UTF-8",
324
- LOGNAME: process.env.LOGNAME,
325
- NO_COLOR: "1",
326
- PATH: process.env.PATH,
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
+ }