@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/README.md
CHANGED
|
@@ -1,45 +1,15 @@
|
|
|
1
1
|
# `@emseepea/testing`
|
|
2
2
|
|
|
3
|
-
Test
|
|
4
|
-
|
|
3
|
+
Test whether an AI selects the right MCP tool and understands its result, not
|
|
4
|
+
just whether the server returns valid data. Write JavaScript tests with ordinary
|
|
5
|
+
imports, setup hooks, loops, and assertions.
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
Promptfoo and a model command supplied by the caller.
|
|
7
|
+
Read the [guide to testing AI tool choice and understanding](https://github.com/windyroad/emseepea/blob/main/website/src/content/docs/ai-tests.md)
|
|
8
|
+
for setup, test fields, commands, and what a passing check proves.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
Start from the [coffee tool-selection test](https://github.com/windyroad/emseepea/blob/main/examples/basic-no-ui/eval/meaning.test.mjs).
|
|
11
|
+
Keep these tests in `eval/`, separate from ordinary tests in `test/`.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
```sh
|
|
18
|
-
npm install --save-dev @emseepea/testing@next
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
Create `eval.yaml` beside the example. Say which server to start, which MCP
|
|
22
|
-
operation to run, and what a correct answer must mean:
|
|
23
|
-
|
|
24
|
-
```yaml
|
|
25
|
-
description: Coffee details keep each concept distinct
|
|
26
|
-
server: ./dist/server.js
|
|
27
|
-
operations:
|
|
28
|
-
- method: tools/call
|
|
29
|
-
name: get-bean-details
|
|
30
|
-
arguments:
|
|
31
|
-
name: Highland Bloom
|
|
32
|
-
question: What are this coffee's origin, variety, process, roast, and tasting notes?
|
|
33
|
-
criticalFacts: [Sample Highlands, Bourbon, natural, medium, berry, cocoa]
|
|
34
|
-
criteria: Keep origin, variety, process, roast, and tasting notes distinct.
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
Build the example, then run:
|
|
38
|
-
|
|
39
|
-
```sh
|
|
40
|
-
npx emseepea-test eval.yaml
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
The check calls the server through the official MCP client. It runs three
|
|
44
|
-
answers and three independent judgments per answer, and saves the evidence
|
|
45
|
-
under `artifacts/llm-eval/`.
|
|
13
|
+
Use `toolSelectionTest` when the AI should choose a tool. Use `semanticTest`
|
|
14
|
+
when test code deliberately prepares MCP resources, prompts, or several results
|
|
15
|
+
for the AI to interpret.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emseepea/testing",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.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": {
|
|
@@ -38,8 +42,7 @@
|
|
|
38
42
|
"test:built": "node --test test/*.test.mjs"
|
|
39
43
|
},
|
|
40
44
|
"dependencies": {
|
|
41
|
-
"@modelcontextprotocol/client": "2.0.0"
|
|
42
|
-
"promptfoo": "0.122.1"
|
|
45
|
+
"@modelcontextprotocol/client": "2.0.0"
|
|
43
46
|
},
|
|
44
47
|
"engines": {
|
|
45
48
|
"node": ">=22"
|
package/semantic/case.mjs
CHANGED
|
@@ -1,189 +1,87 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, resolve } from "node:path";
|
|
3
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { dirname } from "node:path";
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
for (const key of ["description", "question", "criteria", "server"]) {
|
|
12
|
-
if (typeof value[key] !== "string" || value[key].trim() === "") fail(`${key} must be text`);
|
|
13
|
-
}
|
|
14
|
-
if (!Array.isArray(value.criticalFacts) || value.criticalFacts.some((fact) => typeof fact !== "string" || !fact)) {
|
|
15
|
-
fail("criticalFacts must be a non-empty text list");
|
|
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");
|
|
4
|
+
export function validateSemanticCase(value) {
|
|
5
|
+
const result = validateCommon(value);
|
|
6
|
+
if (typeof value.exercise !== "function") throw new Error("exercise must be a function");
|
|
7
|
+
if (!Array.isArray(value.requiredPaths) || !value.requiredPaths.length || value.requiredPaths.some((path) =>
|
|
8
|
+
typeof path !== "string" || !/^(tools\/call|resources\/read|prompts\/get):.+$/.test(path))) {
|
|
9
|
+
throw new Error("requiredPaths must name a supported MCP method and target");
|
|
24
10
|
}
|
|
25
|
-
|
|
26
|
-
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function validateToolSelectionCase(value) {
|
|
15
|
+
const result = validateCommon(value);
|
|
16
|
+
if (!Array.isArray(value.expectedTools) || value.expectedTools.length < 1 || value.expectedTools.length > 3
|
|
17
|
+
|| value.expectedTools.some((name) => typeof name !== "string" || !name.trim())) {
|
|
18
|
+
throw new Error("expectedTools must name between one and three tool calls");
|
|
27
19
|
}
|
|
28
|
-
if (value.
|
|
29
|
-
|
|
20
|
+
if (value.exercise !== undefined || value.requiredPaths !== undefined) {
|
|
21
|
+
throw new Error("toolSelectionTest chooses calls from expectedTools; do not provide exercise or requiredPaths");
|
|
30
22
|
}
|
|
31
|
-
if (value.authToken !== undefined && typeof value.authToken !== "string") fail("authToken must be text");
|
|
32
23
|
return {
|
|
33
|
-
...
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
server: resolve(dirname(absolutePath), value.server),
|
|
24
|
+
...result,
|
|
25
|
+
expectedTools: [...value.expectedTools],
|
|
26
|
+
requiredPaths: [...new Set(value.expectedTools.map((name) => `tools/call:${name}`))],
|
|
37
27
|
};
|
|
38
28
|
}
|
|
39
29
|
|
|
40
|
-
export function
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
}
|
|
30
|
+
export function parseToolSelection(output, advertisedTools, expectedTools) {
|
|
31
|
+
let plan;
|
|
32
|
+
try { plan = JSON.parse(output); } catch { throw new Error("Tool selection must be valid JSON"); }
|
|
33
|
+
if (!plan || typeof plan !== "object" || Array.isArray(plan) || Object.keys(plan).join(",") !== "calls"
|
|
34
|
+
|| !Array.isArray(plan.calls) || plan.calls.length < 1 || plan.calls.length > 3) {
|
|
35
|
+
throw new Error("Tool selection must contain between one and three calls");
|
|
86
36
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
}
|
|
37
|
+
const advertised = new Set(advertisedTools.map(({ name }) => name));
|
|
38
|
+
const calls = plan.calls.map((call) => {
|
|
39
|
+
if (!call || typeof call !== "object" || Array.isArray(call)
|
|
40
|
+
|| Object.keys(call).sort().join(",") !== "arguments,name"
|
|
41
|
+
|| typeof call.name !== "string" || !advertised.has(call.name)
|
|
42
|
+
|| !call.arguments || typeof call.arguments !== "object" || Array.isArray(call.arguments)) {
|
|
43
|
+
throw new Error("Tool selection contains an invalid or unadvertised call");
|
|
122
44
|
}
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
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;
|
|
45
|
+
return { name: call.name, arguments: call.arguments };
|
|
46
|
+
});
|
|
47
|
+
if (calls.map(({ name }) => name).join("\n") !== expectedTools.join("\n")) {
|
|
48
|
+
throw new Error("Model selected the wrong tool sequence");
|
|
136
49
|
}
|
|
137
|
-
|
|
138
|
-
return { value: items, next: index };
|
|
50
|
+
return calls;
|
|
139
51
|
}
|
|
140
52
|
|
|
141
|
-
function
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (!lines[index].startsWith(indent)) break;
|
|
146
|
-
applyPair(object, lines[index].slice(indent.length), parseScalars);
|
|
147
|
-
index += 1;
|
|
53
|
+
function validateCommon(value) {
|
|
54
|
+
if (!value || typeof value !== "object") throw new Error("Semantic test needs options");
|
|
55
|
+
for (const key of ["question", "criteria"]) {
|
|
56
|
+
if (typeof value[key] !== "string" || !value[key].trim()) throw new Error(`${key} must be text`);
|
|
148
57
|
}
|
|
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;
|
|
58
|
+
if (!(value.server instanceof URL) || value.server.protocol !== "file:") throw new Error("server must be a file URL");
|
|
59
|
+
if (!Array.isArray(value.criticalFacts) || !value.criticalFacts.length || value.criticalFacts.some((item) =>
|
|
60
|
+
!(item instanceof RegExp) && (typeof item !== "string" || !item.trim()))) {
|
|
61
|
+
throw new Error("criticalFacts must contain text or regular expressions");
|
|
159
62
|
}
|
|
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);
|
|
63
|
+
if (value.assertAnswer !== undefined && typeof value.assertAnswer !== "function") throw new Error("assertAnswer must be a function");
|
|
64
|
+
for (const key of ["authToken", "authTokenEnvironment"]) {
|
|
65
|
+
if (value[key] !== undefined && (typeof value[key] !== "string" || !value[key].trim())) throw new Error(`${key} must be non-empty text`);
|
|
173
66
|
}
|
|
174
|
-
if (
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (Number.isFinite(number)) return number;
|
|
67
|
+
if (value.authToken !== undefined && value.authTokenEnvironment !== undefined) throw new Error("Choose one authentication source");
|
|
68
|
+
if (value.environment !== undefined && (!value.environment || typeof value.environment !== "object" ||
|
|
69
|
+
Array.isArray(value.environment) || Object.values(value.environment).some((item) => typeof item !== "string"))) {
|
|
70
|
+
throw new Error("environment must contain string values");
|
|
179
71
|
}
|
|
180
|
-
|
|
72
|
+
const server = fileURLToPath(value.server);
|
|
73
|
+
return { ...value, server, directory: dirname(server) };
|
|
181
74
|
}
|
|
182
75
|
|
|
183
|
-
function
|
|
184
|
-
const
|
|
185
|
-
if (
|
|
186
|
-
|
|
76
|
+
export function checkMeaningEvidence(options, answer, paths) {
|
|
77
|
+
const seen = new Set(paths.map(({ method, target }) => `${method}:${target}`));
|
|
78
|
+
if (options.requiredPaths.some((path) => !seen.has(path))) throw new Error("Required MCP path evidence is missing");
|
|
79
|
+
const missingFactIndices = options.criticalFacts.flatMap((fact, index) =>
|
|
80
|
+
(fact instanceof RegExp ? new RegExp(fact.source, fact.flags).test(answer)
|
|
81
|
+
: answer.toLowerCase().includes(fact.toLowerCase())) ? [] : [index]);
|
|
82
|
+
if (missingFactIndices.length) {
|
|
83
|
+
throw Object.assign(new Error("Answer is missing a required fact"), {
|
|
84
|
+
code: "missing-critical-facts", missingFactIndices,
|
|
85
|
+
});
|
|
187
86
|
}
|
|
188
|
-
return trimmed;
|
|
189
87
|
}
|