@emseepea/testing 0.18.0 → 0.19.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
@@ -22,6 +22,13 @@ it with `assertToolArguments` for the stable call and `assertFeedback` for the
22
22
  feedback observation and important detail. Keep `assertToolCalls` when every
23
23
  complete argument should match exactly and no feedback tool is advertised.
24
24
 
25
+ For an intentionally optional permission check, use
26
+ `assertToolNames(turn, { oneOf: [["update"], ["permissions", "update"]] })`.
27
+ Supply one to eight explicit sequences. Each trial must match a complete
28
+ sequence in the listed order and count. The evidence retains all alternatives
29
+ and the matched sequence; arguments, meaning, confirmation and feedback still
30
+ need their own assertions.
31
+
25
32
  For a successful turn that advertises feedback, use
26
33
  `assertToolCallsWithOptionalFeedback`. It requires the exact ordered primary
27
34
  calls and arguments, then allows no feedback call or one trailing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emseepea/testing",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "MCP integration and semantic testing helpers",
5
5
  "license": "MIT",
6
6
  "repository": {
package/semantic/cli.mjs CHANGED
@@ -172,6 +172,15 @@ function validToolAssertions(turn, isHash) {
172
172
  || (turn.toolCalls.length === 1 && turn.toolCalls[0].name === turn.expectedOptionalTool));
173
173
  }
174
174
  if (JSON.stringify(turn.selectedTools) !== JSON.stringify(turn.expectedTools)) return false;
175
+ if (turn.expectedToolAlternatives !== undefined) {
176
+ const alternatives = turn.expectedToolAlternatives;
177
+ if (!Array.isArray(alternatives) || alternatives.length < 1 || alternatives.length > 8
178
+ || !alternatives.every((names) => Array.isArray(names)
179
+ && names.every((name) => typeof name === "string" && name.trim()))
180
+ || !alternatives.some((names) => JSON.stringify(names) === JSON.stringify(turn.expectedTools))) {
181
+ return false;
182
+ }
183
+ }
175
184
  if (isHash(turn.expectedCallsSha256)) {
176
185
  return JSON.stringify(turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))
177
186
  === JSON.stringify(turn.expectedCalls);
@@ -181,6 +190,8 @@ function validToolAssertions(turn, isHash) {
181
190
  tools: turn.expectedTools,
182
191
  arguments: turn.expectedArguments,
183
192
  feedback: turn.expectedFeedback,
193
+ ...(turn.expectedToolAlternatives === undefined
194
+ ? {} : { alternatives: turn.expectedToolAlternatives }),
184
195
  })).digest("hex");
185
196
  if (expectedHash !== turn.expectedSelectionSha256) return false;
186
197
  for (const [name, expected] of Object.entries(turn.expectedArguments ?? {})) {
@@ -54,7 +54,11 @@ export function assertToolCallsWithOptionalFeedback(
54
54
  export function assertNoToolCalls(turn: ConversationTurn): void;
55
55
  /** Allows no tool call or exactly one call to the named tool in each trial. */
56
56
  export function assertOptionalToolCall(turn: ConversationTurn, name: string): void;
57
- export function assertToolNames(turn: ConversationTurn, expected: readonly string[]): void;
57
+ /** Requires an exact sequence, or one of at most eight explicitly authored sequences. */
58
+ export function assertToolNames(
59
+ turn: ConversationTurn,
60
+ expected: readonly string[] | { readonly oneOf: readonly (readonly string[])[] },
61
+ ): void;
58
62
  export function assertToolArguments(
59
63
  turn: ConversationTurn,
60
64
  name: string,
package/semantic/test.mjs CHANGED
@@ -268,15 +268,28 @@ export function assertOptionalToolCall(turn, name) {
268
268
 
269
269
  export function assertToolNames(turn, expected) {
270
270
  const trials = turnTrials(turn);
271
- if (!Array.isArray(expected) || expected.some((name) => typeof name !== "string" || !name.trim())) {
271
+ const validNames = (names) => Array.isArray(names)
272
+ && names.every((name) => typeof name === "string" && name.trim());
273
+ const alternatives = Array.isArray(expected) ? undefined : expected?.oneOf;
274
+ if (alternatives !== undefined && (!Array.isArray(alternatives)
275
+ || alternatives.length < 1 || alternatives.length > 8 || !alternatives.every(validNames))) {
276
+ throw new Error("Expected tool alternatives must contain one to eight string arrays");
277
+ }
278
+ if (alternatives === undefined && !validNames(expected)) {
272
279
  throw new Error("Expected tool names must be a string array");
273
280
  }
281
+ const sequences = (alternatives ?? [expected]).map((names) => [...names]);
274
282
  for (const trial of trials) {
275
- trial.record.expectedTools = expected;
283
+ const actual = trial.calls.map(({ name }) => name);
284
+ const match = sequences.find((names) => JSON.stringify(names) === JSON.stringify(actual));
285
+ trial.record.expectedTools = match ?? sequences[0];
286
+ if (alternatives !== undefined) trial.record.expectedToolAlternatives = sequences;
276
287
  recordFlexibleExpectation(trial.record);
277
288
  }
278
289
  try {
279
- for (const trial of trials) assert.deepStrictEqual(trial.calls.map(({ name }) => name), expected);
290
+ for (const trial of trials) {
291
+ assert.deepStrictEqual(trial.calls.map(({ name }) => name), trial.record.expectedTools);
292
+ }
280
293
  } catch {
281
294
  failAssertion(trials, "tool-name assertion");
282
295
  throw new Error("Tool names did not match the expected order and count");
@@ -552,6 +565,8 @@ function recordFlexibleExpectation(record) {
552
565
  tools: record.expectedTools,
553
566
  arguments: record.expectedArguments,
554
567
  feedback: record.expectedFeedback,
568
+ ...(record.expectedToolAlternatives === undefined
569
+ ? {} : { alternatives: record.expectedToolAlternatives }),
555
570
  }));
556
571
  }
557
572