@emseepea/testing 0.0.1 → 0.1.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 +77 -32
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -0
- package/package.json +7 -3
- package/semantic/case.mjs +34 -179
- package/semantic/cli.mjs +88 -382
- package/semantic/discover.mjs +20 -0
- package/semantic/material.mjs +52 -20
- package/semantic/provider.mjs +13 -73
- package/semantic/test.d.mts +21 -0
- package/semantic/test.mjs +92 -0
package/README.md
CHANGED
|
@@ -1,45 +1,90 @@
|
|
|
1
1
|
# `@emseepea/testing`
|
|
2
2
|
|
|
3
|
-
Test
|
|
4
|
-
|
|
3
|
+
Test whether an AI understands your MCP server's results, not just whether
|
|
4
|
+
the server returns valid data. Write the checks in JavaScript, with ordinary
|
|
5
|
+
imports, setup hooks, loops, and assertions.
|
|
5
6
|
|
|
6
|
-
The
|
|
7
|
-
client, and records the MCP operations used by each check. Semantic checks use
|
|
8
|
-
Promptfoo and a model command supplied by the caller.
|
|
7
|
+
The code-first API below is being prepared for the next package release.
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
pre-alpha release.
|
|
9
|
+
## Keep the Two Kinds of Test Separate
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
Each example has two directories:
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
- `test/` holds ordinary tests. Run them with `npm test`.
|
|
14
|
+
- `eval/` holds tests that ask a language model to interpret MCP results.
|
|
15
|
+
Run them with `npm run test:llm`.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
The commands do not run each other's tests. Both directories are linted.
|
|
18
|
+
Organize larger suites into subdirectories, such as `eval/inventory/`.
|
|
19
|
+
The LLM runner finds every `*.test.mjs` file inside `eval/`.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
21
|
+
## Write an LLM Test
|
|
22
|
+
|
|
23
|
+
Import `semanticTest` from `@emseepea/testing/semantic` in a file such as
|
|
24
|
+
`eval/coffee.test.mjs`. Start with the
|
|
25
|
+
[basic coffee test](https://github.com/windyroad/emseepea/blob/main/examples/basic-no-ui/eval/meaning.test.mjs)
|
|
26
|
+
or the
|
|
27
|
+
[report test with repeated calls](https://github.com/windyroad/emseepea/blob/main/examples/multi-instance/eval/meaning.test.mjs).
|
|
28
|
+
|
|
29
|
+
Each test describes:
|
|
30
|
+
|
|
31
|
+
- `server`: the file URL of your built server entry point. It must print its
|
|
32
|
+
local `http://127.0.0.1:PORT/mcp` address when ready.
|
|
33
|
+
- `exercise`: code that calls tools, reads resources, or requests prompts
|
|
34
|
+
through the supplied MCP client. Use assertions to check the returned data.
|
|
35
|
+
- `question`: what to ask the model about those results.
|
|
36
|
+
- `criticalFacts`: text or regular expressions that every answer must match.
|
|
37
|
+
Text checks ignore letter case; patterns use their own flags.
|
|
38
|
+
- `criteria`: what a correct answer must mean, including mistakes to avoid.
|
|
39
|
+
- `requiredPaths`: which MCP operations must supply the evidence, written as
|
|
40
|
+
`method:target`, such as `tools/call:get-bean-details`.
|
|
41
|
+
|
|
42
|
+
Use the optional `assertAnswer(answer)` callback for your own exact checks on
|
|
43
|
+
the model's answer. Each test needs a unique name within its file.
|
|
44
|
+
|
|
45
|
+
The repeated-report example asks for JSON and checks every field; it does not
|
|
46
|
+
test free-form prose.
|
|
47
|
+
|
|
48
|
+
The callback's client supports `callTool`, `readResource`, and `getPrompt`.
|
|
49
|
+
Pass request parameters as the single argument to each method. The helper
|
|
50
|
+
records every call and includes tool progress in the model's input.
|
|
36
51
|
|
|
37
|
-
|
|
52
|
+
## Run the Checks
|
|
53
|
+
|
|
54
|
+
In a copied example, install its dependencies and run:
|
|
38
55
|
|
|
39
56
|
```sh
|
|
40
|
-
|
|
57
|
+
npm test
|
|
58
|
+
npm run lint
|
|
59
|
+
npm run test:llm
|
|
41
60
|
```
|
|
42
61
|
|
|
43
|
-
The
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
The last command requires the Claude CLI on your command path and a signed-in
|
|
63
|
+
Claude account. If needed, run `claude auth login` first. This package does not
|
|
64
|
+
bundle the CLI or copy your login credentials.
|
|
65
|
+
|
|
66
|
+
For your own project, add `@emseepea/testing` as a development dependency and
|
|
67
|
+
set `test:llm` to build your server and run `emseepea-test eval`.
|
|
68
|
+
|
|
69
|
+
For a server that requires sign-in, set `authTokenEnvironment` to the name of
|
|
70
|
+
an environment variable containing its test access token. Do not put real
|
|
71
|
+
tokens in test files. The optional `environment` object supplies ordinary test
|
|
72
|
+
settings to the server; model-provider credentials are not passed through.
|
|
73
|
+
|
|
74
|
+
## What a Passing Check Means
|
|
75
|
+
|
|
76
|
+
For each test, the runner asks the same question three times. Each answer gets
|
|
77
|
+
three independent model judgments, for nine judgments in total. A missing
|
|
78
|
+
fact, failed assertion, rejected answer, or missing MCP call fails the test.
|
|
79
|
+
Failed answers are not retried or taken from a cache.
|
|
80
|
+
|
|
81
|
+
Your test code makes the MCP calls. The model interprets the collected results;
|
|
82
|
+
it cannot call tools itself. These checks prove interpretation of that material,
|
|
83
|
+
not that the model can independently choose the right tool.
|
|
84
|
+
|
|
85
|
+
Results are saved to `artifacts/llm-eval/evidence.json`. The report contains
|
|
86
|
+
outcomes and operation hashes, not raw MCP results or model answers.
|
|
87
|
+
|
|
88
|
+
Repository tests also use a simulated model to check the runner without spending
|
|
89
|
+
model credits. Those `--smoke` checks test the wiring only. They do not prove
|
|
90
|
+
that a real model understands the result and cannot approve a release.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/client";
|
|
2
|
+
export interface TestCleanup {
|
|
3
|
+
after(cleanup: () => Promise<void>): void;
|
|
4
|
+
}
|
|
5
|
+
export interface StartMcpServerOptions {
|
|
6
|
+
clientName?: string;
|
|
7
|
+
environment?: NodeJS.ProcessEnv;
|
|
8
|
+
startupTimeoutMs?: number;
|
|
9
|
+
token?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RunningMcpServer {
|
|
12
|
+
connect(token?: string): Promise<Client>;
|
|
13
|
+
url: URL;
|
|
14
|
+
}
|
|
15
|
+
export declare function startMcpServer(test: TestCleanup, serverUrl: URL, options?: StartMcpServerOptions): Promise<RunningMcpServer>;
|
|
16
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAiC,MAAM,8BAA8B,CAAC;AAErF,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CAC3C;AAED,MAAM,WAAW,qBAAqB;IACpC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAChC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACzC,GAAG,EAAE,GAAG,CAAC;CACV;AAED,wBAAsB,cAAc,CAClC,IAAI,EAAE,WAAW,EACjB,SAAS,EAAE,GAAG,EACd,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,gBAAgB,CAAC,CAgE3B"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
4
|
+
export async function startMcpServer(test, serverUrl, options = {}) {
|
|
5
|
+
const child = spawn(process.execPath, [fileURLToPath(serverUrl)], {
|
|
6
|
+
env: {
|
|
7
|
+
...process.env,
|
|
8
|
+
NODE_ENV: "test",
|
|
9
|
+
PORT: "0",
|
|
10
|
+
...options.environment,
|
|
11
|
+
},
|
|
12
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
13
|
+
});
|
|
14
|
+
const clients = [];
|
|
15
|
+
let output = "";
|
|
16
|
+
let errors = "";
|
|
17
|
+
child.stdout.on("data", (chunk) => { output = `${output}${chunk}`.slice(-16_384); });
|
|
18
|
+
child.stderr.on("data", (chunk) => { errors = `${errors}${chunk}`.slice(-16_384); });
|
|
19
|
+
const url = await new Promise((resolve, reject) => {
|
|
20
|
+
const finish = (error, value) => {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
child.stdout.off("data", inspect);
|
|
23
|
+
child.off("error", failed);
|
|
24
|
+
child.off("exit", exited);
|
|
25
|
+
if (error)
|
|
26
|
+
reject(error);
|
|
27
|
+
else if (value)
|
|
28
|
+
resolve(value);
|
|
29
|
+
};
|
|
30
|
+
const inspect = () => {
|
|
31
|
+
const match = output.match(/http:\/\/127\.0\.0\.1:\d+\/mcp/);
|
|
32
|
+
if (match?.[0])
|
|
33
|
+
finish(undefined, new URL(match[0]));
|
|
34
|
+
};
|
|
35
|
+
const failed = (error) => finish(error);
|
|
36
|
+
const exited = (code) => finish(new Error(`MCP server exited ${String(code)}: ${errors}`));
|
|
37
|
+
const timer = setTimeout(() => finish(new Error(`MCP server startup timed out: ${errors}`)), options.startupTimeoutMs ?? 15_000);
|
|
38
|
+
child.stdout.on("data", inspect);
|
|
39
|
+
child.once("error", failed);
|
|
40
|
+
child.once("exit", exited);
|
|
41
|
+
inspect();
|
|
42
|
+
}).catch(async (error) => {
|
|
43
|
+
await stopProcess(child);
|
|
44
|
+
throw error;
|
|
45
|
+
});
|
|
46
|
+
test.after(async () => {
|
|
47
|
+
await Promise.allSettled(clients.map((client) => client.close()));
|
|
48
|
+
await stopProcess(child);
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
url,
|
|
52
|
+
async connect(token = options.token) {
|
|
53
|
+
const client = new Client({ name: options.clientName ?? "emseepea-test", version: "0.0.0" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
|
|
54
|
+
await client.connect(new StreamableHTTPClientTransport(url, token ? { authProvider: { token: async () => token } } : undefined));
|
|
55
|
+
clients.push(client);
|
|
56
|
+
return client;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function stopProcess(child) {
|
|
61
|
+
if (child.exitCode !== null)
|
|
62
|
+
return;
|
|
63
|
+
child.kill("SIGTERM");
|
|
64
|
+
await Promise.race([
|
|
65
|
+
new Promise((resolve) => child.once("close", () => resolve())),
|
|
66
|
+
new Promise((resolve) => setTimeout(resolve, 3_000)),
|
|
67
|
+
]);
|
|
68
|
+
if (child.exitCode === null)
|
|
69
|
+
child.kill("SIGKILL");
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,MAAM,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAkBrF,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,IAAiB,EACjB,SAAc,EACd,UAAiC,EAAE;IAEnC,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,EAAE;QAChE,GAAG,EAAE;YACH,GAAG,OAAO,CAAC,GAAG;YACd,QAAQ,EAAE,MAAM;YAChB,IAAI,EAAE,GAAG;YACT,GAAG,OAAO,CAAC,WAAW;SACvB;QACD,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC;KAClC,CAAC,CAAC;IACH,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE,GAAG,MAAM,GAAG,GAAG,MAAM,GAAG,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7F,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE,GAAG,MAAM,GAAG,GAAG,MAAM,GAAG,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAE7F,MAAM,GAAG,GAAG,MAAM,IAAI,OAAO,CAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrD,MAAM,MAAM,GAAG,CAAC,KAAa,EAAE,KAAW,EAAE,EAAE;YAC5C,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YAClC,KAAK,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YAC3B,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;YAC1B,IAAI,KAAK;gBAAE,MAAM,CAAC,KAAK,CAAC,CAAC;iBACpB,IAAI,KAAK;gBAAE,OAAO,CAAC,KAAK,CAAC,CAAC;QACjC,CAAC,CAAC;QACF,MAAM,OAAO,GAAG,GAAG,EAAE;YACnB,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,gCAAgC,CAAC,CAAC;YAC7D,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC;gBAAE,MAAM,CAAC,SAAS,EAAE,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACvD,CAAC,CAAC;QACF,MAAM,MAAM,GAAG,CAAC,KAAY,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAC/C,MAAM,MAAM,GAAG,CAAC,IAAmB,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,qBAAqB,MAAM,CAAC,IAAI,CAAC,KAAK,MAAM,EAAE,CAAC,CAAC,CAAC;QAC1G,MAAM,KAAK,GAAG,UAAU,CACtB,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,iCAAiC,MAAM,EAAE,CAAC,CAAC,EAClE,OAAO,CAAC,gBAAgB,IAAI,MAAM,CACnC,CAAC;QACF,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACjC,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC5B,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC3B,OAAO,EAAE,CAAC;IACZ,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,EAAE,KAAc,EAAE,EAAE;QAChC,MAAM,WAAW,CAAC,KAAK,CAAC,CAAC;QACzB,MAAM,KAAK,CAAC;IACd,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE;QACpB,MAAM,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;QAClE,MAAM,WAAW,CAAC,KAAK,CAAC,CAAC;IAC3B,CAAC,CAAC,CAAC;IAEH,OAAO;QACL,GAAG;QACH,KAAK,CAAC,OAAO,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK;YACjC,MAAM,MAAM,GAAG,IAAI,MAAM,CACvB,EAAE,IAAI,EAAE,OAAO,CAAC,UAAU,IAAI,eAAe,EAAE,OAAO,EAAE,OAAO,EAAE,EACjE,EAAE,kBAAkB,EAAE,EAAE,IAAI,EAAE,EAAE,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,CACxD,CAAC;YACF,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,6BAA6B,CACpD,GAAG,EACH,KAAK,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,EAAE,KAAK,EAAE,KAAK,IAAI,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CACnE,CAAC,CAAC;YACH,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACrB,OAAO,MAAM,CAAC;QAChB,CAAC;KACF,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,WAAW,CAAC,KAA+B;IACxD,IAAI,KAAK,CAAC,QAAQ,KAAK,IAAI;QAAE,OAAO;IACpC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IACtB,MAAM,OAAO,CAAC,IAAI,CAAC;QACjB,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC;QACpE,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;KAC3D,CAAC,CAAC;IACH,IAAI,KAAK,CAAC,QAAQ,KAAK,IAAI;QAAE,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;AACrD,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emseepea/testing",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "MCP integration and semantic testing helpers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
".": {
|
|
21
21
|
"types": "./dist/index.d.ts",
|
|
22
22
|
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./semantic": {
|
|
25
|
+
"types": "./semantic/test.d.mts",
|
|
26
|
+
"import": "./semantic/test.mjs"
|
|
23
27
|
}
|
|
24
28
|
},
|
|
25
29
|
"bin": {
|
|
@@ -33,12 +37,12 @@
|
|
|
33
37
|
],
|
|
34
38
|
"scripts": {
|
|
35
39
|
"build": "tsc -p tsconfig.json",
|
|
40
|
+
"prepack": "npm run build",
|
|
36
41
|
"test": "npm run build && npm run test:built",
|
|
37
42
|
"test:built": "node --test test/*.test.mjs"
|
|
38
43
|
},
|
|
39
44
|
"dependencies": {
|
|
40
|
-
"@modelcontextprotocol/client": "2.0.0"
|
|
41
|
-
"promptfoo": "0.122.1"
|
|
45
|
+
"@modelcontextprotocol/client": "2.0.0"
|
|
42
46
|
},
|
|
43
47
|
"engines": {
|
|
44
48
|
"node": ">=22"
|
package/semantic/case.mjs
CHANGED
|
@@ -1,189 +1,44 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, resolve } from "node:path";
|
|
3
1
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
if (!Array.isArray(value.operations) || value.operations.length === 0) fail("operations must not be empty");
|
|
18
|
-
for (const operation of value.operations) {
|
|
19
|
-
if (!operation || typeof operation !== "object" || !methods.has(operation.method)) {
|
|
20
|
-
fail("each operation needs a supported method");
|
|
21
|
-
}
|
|
22
|
-
const target = operation.name ?? operation.uri;
|
|
23
|
-
if (typeof target !== "string" || target === "") fail("each operation needs a name or uri");
|
|
24
|
-
}
|
|
25
|
-
if (value.environment !== undefined && (!value.environment || typeof value.environment !== "object" || Array.isArray(value.environment))) {
|
|
26
|
-
fail("environment must be an object");
|
|
27
|
-
}
|
|
28
|
-
if (value.authTokenEnvironment !== undefined && typeof value.authTokenEnvironment !== "string") {
|
|
29
|
-
fail("authTokenEnvironment must be text");
|
|
30
|
-
}
|
|
31
|
-
if (value.authToken !== undefined && typeof value.authToken !== "string") fail("authToken must be text");
|
|
32
|
-
return {
|
|
33
|
-
...value,
|
|
34
|
-
path: absolutePath,
|
|
35
|
-
directory: dirname(absolutePath),
|
|
36
|
-
server: resolve(dirname(absolutePath), value.server),
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function requiredPaths(testCase) {
|
|
41
|
-
return testCase.operations.map((operation) => (
|
|
42
|
-
`${operation.method}:${operation.name ?? operation.uri}`
|
|
43
|
-
));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function fail(message) {
|
|
47
|
-
throw new Error(`Invalid semantic case: ${message}`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function parseCase(source) {
|
|
51
|
-
const lines = source.split(/\r?\n/);
|
|
52
|
-
const value = {};
|
|
53
|
-
for (let index = 0; index < lines.length;) {
|
|
54
|
-
const line = lines[index];
|
|
55
|
-
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
|
56
|
-
index += 1;
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
const top = /^([A-Za-z][A-Za-z0-9]*):(?:\s*(.*))?$/.exec(line);
|
|
60
|
-
if (!top) fail(`unsupported line: ${line}`);
|
|
61
|
-
const [, key, raw = ""] = top;
|
|
62
|
-
const rest = raw.trimEnd();
|
|
63
|
-
if (key === "operations") {
|
|
64
|
-
const parsed = parseOperations(lines, index + 1);
|
|
65
|
-
value.operations = parsed.value;
|
|
66
|
-
index = parsed.next;
|
|
67
|
-
} else if (rest === ">-") {
|
|
68
|
-
const parsed = parseFoldedText(lines, index + 1);
|
|
69
|
-
value[key] = parsed.value;
|
|
70
|
-
index = parsed.next;
|
|
71
|
-
} else if (key === "environment" && rest === "") {
|
|
72
|
-
const parsed = parseObject(lines, index + 1, " ");
|
|
73
|
-
value.environment = parsed.value;
|
|
74
|
-
index = parsed.next;
|
|
75
|
-
} else if (rest === "") {
|
|
76
|
-
const parsed = parseList(lines, index + 1);
|
|
77
|
-
value[key] = parsed.value;
|
|
78
|
-
index = parsed.next;
|
|
79
|
-
} else if (rest.startsWith("[") && rest.endsWith("]")) {
|
|
80
|
-
value[key] = rest.slice(1, -1).split(",").map((item) => unquote(item.trim())).filter(Boolean);
|
|
81
|
-
index += 1;
|
|
82
|
-
} else {
|
|
83
|
-
value[key] = unquote(rest);
|
|
84
|
-
index += 1;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return value;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function parseOperations(lines, start) {
|
|
91
|
-
const operations = [];
|
|
92
|
-
let index = start;
|
|
93
|
-
while (index < lines.length) {
|
|
94
|
-
const line = lines[index];
|
|
95
|
-
if (line.trim() === "") {
|
|
96
|
-
index += 1;
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
if (!line.startsWith(" - ")) break;
|
|
100
|
-
const operation = {};
|
|
101
|
-
const first = line.slice(4);
|
|
102
|
-
applyPair(operation, first);
|
|
103
|
-
index += 1;
|
|
104
|
-
while (index < lines.length) {
|
|
105
|
-
const current = lines[index];
|
|
106
|
-
if (current.trim() === "") {
|
|
107
|
-
index += 1;
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (current.startsWith(" - ") || !current.startsWith(" ")) break;
|
|
111
|
-
const pair = /^ ([A-Za-z][A-Za-z0-9]*):(?:\s*(.*))?$/.exec(current);
|
|
112
|
-
if (!pair) fail(`unsupported operation line: ${current}`);
|
|
113
|
-
const [, key, raw = ""] = pair;
|
|
114
|
-
if (key === "arguments" && raw.trimEnd() === "") {
|
|
115
|
-
const parsed = parseObject(lines, index + 1, " ", true);
|
|
116
|
-
operation.arguments = parsed.value;
|
|
117
|
-
index = parsed.next;
|
|
118
|
-
} else {
|
|
119
|
-
operation[key] = unquote(raw.trimEnd());
|
|
120
|
-
index += 1;
|
|
121
|
-
}
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function validateSemanticCase(value) {
|
|
5
|
+
if (!value || typeof value !== "object") throw new Error("Semantic test needs options");
|
|
6
|
+
for (const key of ["question", "criteria"]) {
|
|
7
|
+
if (typeof value[key] !== "string" || !value[key].trim()) throw new Error(`${key} must be text`);
|
|
8
|
+
}
|
|
9
|
+
if (!(value.server instanceof URL) || value.server.protocol !== "file:") throw new Error("server must be a file URL");
|
|
10
|
+
for (const key of ["criticalFacts", "requiredPaths"]) {
|
|
11
|
+
if (!Array.isArray(value[key]) || !value[key].length || value[key].some((item) =>
|
|
12
|
+
!(key === "criticalFacts" && item instanceof RegExp) && (typeof item !== "string" || !item.trim()))) {
|
|
13
|
+
throw new Error(`${key} must contain text${key === "criticalFacts" ? " or regular expressions" : ""}`);
|
|
122
14
|
}
|
|
123
|
-
operations.push(operation);
|
|
124
|
-
}
|
|
125
|
-
return { value: operations, next: index };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function parseList(lines, start) {
|
|
129
|
-
const items = [];
|
|
130
|
-
let index = start;
|
|
131
|
-
while (index < lines.length) {
|
|
132
|
-
const item = /^ -\s+(.+)$/.exec(lines[index]);
|
|
133
|
-
if (!item) break;
|
|
134
|
-
items.push(unquote(item[1].trimEnd()));
|
|
135
|
-
index += 1;
|
|
136
|
-
}
|
|
137
|
-
if (items.length === 0) fail("expected a list");
|
|
138
|
-
return { value: items, next: index };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function parseObject(lines, start, indent, parseScalars = false) {
|
|
142
|
-
const object = {};
|
|
143
|
-
let index = start;
|
|
144
|
-
while (index < lines.length) {
|
|
145
|
-
if (!lines[index].startsWith(indent)) break;
|
|
146
|
-
applyPair(object, lines[index].slice(indent.length), parseScalars);
|
|
147
|
-
index += 1;
|
|
148
15
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const parts = [];
|
|
154
|
-
let index = start;
|
|
155
|
-
while (index < lines.length) {
|
|
156
|
-
if (!lines[index].startsWith(" ")) break;
|
|
157
|
-
parts.push(lines[index].trim());
|
|
158
|
-
index += 1;
|
|
16
|
+
if (typeof value.exercise !== "function") throw new Error("exercise must be a function");
|
|
17
|
+
if (value.assertAnswer !== undefined && typeof value.assertAnswer !== "function") throw new Error("assertAnswer must be a function");
|
|
18
|
+
if (value.requiredPaths.some((path) => !/^(tools\/call|resources\/read|prompts\/get):.+$/.test(path))) {
|
|
19
|
+
throw new Error("requiredPaths must name a supported MCP method and target");
|
|
159
20
|
}
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function applyPair(target, source, parseScalars = false) {
|
|
164
|
-
const pair = /^([A-Za-z][A-Za-z0-9_]*):\s*(.*)$/.exec(source);
|
|
165
|
-
if (!pair) fail(`expected key-value pair: ${source}`);
|
|
166
|
-
target[pair[1]] = parseScalars ? parseArgumentScalar(pair[2]) : unquote(pair[2].trimEnd());
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function parseArgumentScalar(value) {
|
|
170
|
-
const trimmed = value.trim();
|
|
171
|
-
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
172
|
-
return unquote(trimmed);
|
|
21
|
+
for (const key of ["authToken", "authTokenEnvironment"]) {
|
|
22
|
+
if (value[key] !== undefined && (typeof value[key] !== "string" || !value[key].trim())) throw new Error(`${key} must be non-empty text`);
|
|
173
23
|
}
|
|
174
|
-
if (
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (Number.isFinite(number)) return number;
|
|
24
|
+
if (value.authToken !== undefined && value.authTokenEnvironment !== undefined) throw new Error("Choose one authentication source");
|
|
25
|
+
if (value.environment !== undefined && (!value.environment || typeof value.environment !== "object" ||
|
|
26
|
+
Array.isArray(value.environment) || Object.values(value.environment).some((item) => typeof item !== "string"))) {
|
|
27
|
+
throw new Error("environment must contain string values");
|
|
179
28
|
}
|
|
180
|
-
|
|
29
|
+
const server = fileURLToPath(value.server);
|
|
30
|
+
return { ...value, server, directory: dirname(server) };
|
|
181
31
|
}
|
|
182
32
|
|
|
183
|
-
function
|
|
184
|
-
const
|
|
185
|
-
if (
|
|
186
|
-
|
|
33
|
+
export function checkMeaningEvidence(options, answer, paths) {
|
|
34
|
+
const seen = new Set(paths.map(({ method, target }) => `${method}:${target}`));
|
|
35
|
+
if (options.requiredPaths.some((path) => !seen.has(path))) throw new Error("Required MCP path evidence is missing");
|
|
36
|
+
const missingFactIndices = options.criticalFacts.flatMap((fact, index) =>
|
|
37
|
+
(fact instanceof RegExp ? new RegExp(fact.source, fact.flags).test(answer)
|
|
38
|
+
: answer.toLowerCase().includes(fact.toLowerCase())) ? [] : [index]);
|
|
39
|
+
if (missingFactIndices.length) {
|
|
40
|
+
throw Object.assign(new Error("Answer is missing a required fact"), {
|
|
41
|
+
code: "missing-critical-facts", missingFactIndices,
|
|
42
|
+
});
|
|
187
43
|
}
|
|
188
|
-
return trimmed;
|
|
189
44
|
}
|