@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 CHANGED
@@ -1,45 +1,15 @@
1
1
  # `@emseepea/testing`
2
2
 
3
- Test what a Model Context Protocol (MCP) server returns and whether a language
4
- model understands it.
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
- The package starts a Streamable HTTP server, connects through the official MCP
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
+ 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
- The package is intended to publish under the `next` tag with the first
11
- pre-alpha release.
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
- ## Add Semantic Checks to an Example
14
-
15
- Install the package as a development dependency:
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.2",
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
- const methods = new Set(["tools/call", "resources/read", "prompts/get"]);
6
-
7
- export async function loadSemanticCase(path) {
8
- const absolutePath = path instanceof URL ? fileURLToPath(path) : resolve(path);
9
- const value = parseCase(await readFile(absolutePath, "utf8"));
10
- if (!value || typeof value !== "object" || Array.isArray(value)) fail("must be an object");
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
- if (value.environment !== undefined && (!value.environment || typeof value.environment !== "object" || Array.isArray(value.environment))) {
26
- fail("environment must be an object");
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.authTokenEnvironment !== undefined && typeof value.authTokenEnvironment !== "string") {
29
- fail("authTokenEnvironment must be text");
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
- ...value,
34
- path: absolutePath,
35
- directory: dirname(absolutePath),
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 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
- }
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
- 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
- }
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
- 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;
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
- if (items.length === 0) fail("expected a list");
138
- return { value: items, next: index };
50
+ return calls;
139
51
  }
140
52
 
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;
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
- return { value: object, next: index };
150
- }
151
-
152
- function parseFoldedText(lines, start) {
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
- return { value: parts.join(" "), next: index };
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 (trimmed === "true") return true;
175
- if (trimmed === "false") return false;
176
- if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(trimmed)) {
177
- const number = Number(trimmed);
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
- return trimmed;
72
+ const server = fileURLToPath(value.server);
73
+ return { ...value, server, directory: dirname(server) };
181
74
  }
182
75
 
183
- function unquote(value) {
184
- const trimmed = value.trim();
185
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
186
- return trimmed.slice(1, -1);
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
  }