@rulvar/testing 1.12.0 → 1.14.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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as FAKE_MODEL, c as FakeAdapterOptions, d as FakeToolCallsValue, f as FakeWireErrorValue, i as createTestEngine, l as FakeCall, m as fakeWireError, n as TestEngine, o as FAKE_MODEL_REF, p as fakeToolCalls, r as TestRunHandle, s as FakeAdapter, t as CreateTestEngineOptions, u as FakeResponder } from "./test-engine-2Md1gvT4.js";
1
+ import { a as FAKE_MODEL, c as FakeAdapterOptions, d as FakeToolCallsValue, f as FakeWireErrorValue, i as createTestEngine, l as FakeCall, m as fakeWireError, n as TestEngine, o as FAKE_MODEL_REF, p as fakeToolCalls, r as TestRunHandle, s as FakeAdapter, t as CreateTestEngineOptions, u as FakeResponder } from "./test-engine-DAXwdoOQ.js";
2
2
  import { AgentProfile, ChatEvent, ChatRequest, InvocationRole, JournalEntry, JournalStore, ModelCaps, ModelSpec, ProviderAdapter, ResumePreview, RunOutcome, WireError, Workflow, createEngine } from "@rulvar/core";
3
3
 
4
4
  //#region src/replay-strict.d.ts
@@ -27,6 +27,67 @@ declare function replayRun<A, R>(wf: Workflow<A, R>, args: A, options: ReplayRun
27
27
  preview: ResumePreview;
28
28
  }>;
29
29
  //#endregion
30
+ //#region src/live.d.ts
31
+ /**
32
+ * True only when `RULVAR_LIVE_TESTS` is exactly `'1'` AND every named
33
+ * environment key is set to a non-empty value. Gate live tests as
34
+ * `it.skipIf(!liveTestEnabled('ANTHROPIC_API_KEY'))(...)` so an
35
+ * unrelated key in the shell never triggers a paid provider call from
36
+ * an ordinary test run.
37
+ */
38
+ declare function liveTestEnabled(...requiredEnvKeys: string[]): boolean;
39
+ interface RunLiveSmokeOptions {
40
+ /** Total attempts including the first (default 3, minimum 1). */
41
+ attempts?: number;
42
+ /**
43
+ * Backoff before retry n (1-based) is `baseDelayMs * n` (default
44
+ * 2000). Pass 0 to retry without sleeping (unit tests).
45
+ */
46
+ baseDelayMs?: number;
47
+ }
48
+ /**
49
+ * The classified result of a bounded live smoke. `attempts` is how many
50
+ * streams were actually opened; only `'exhausted'` reaches the
51
+ * configured bound.
52
+ */
53
+ type LiveSmokeOutcome = {
54
+ status: "ok";
55
+ attempts: number;
56
+ events: ChatEvent[];
57
+ } | {
58
+ status: "failed";
59
+ attempts: number;
60
+ error: WireError;
61
+ events: ChatEvent[];
62
+ } | {
63
+ status: "exhausted";
64
+ attempts: number;
65
+ errors: WireError[];
66
+ } | {
67
+ status: "no-terminal";
68
+ attempts: number;
69
+ events: ChatEvent[];
70
+ };
71
+ /**
72
+ * Drains `adapter.stream(req)` with a bounded retry policy and classifies
73
+ * the outcome instead of throwing:
74
+ *
75
+ * - `'ok'`: a `finish` event arrived (the events of the successful
76
+ * attempt are included for further assertions).
77
+ * - `'failed'`: a terminal error with `retryable: false`; never retried,
78
+ * diagnostics preserved.
79
+ * - `'exhausted'`: every attempt ended in a `retryable: true` error; the
80
+ * per-attempt errors are preserved in order.
81
+ * - `'no-terminal'`: the stream ended with neither `finish` nor `error`,
82
+ * which violates the provider SPI; never retried (spending again on a
83
+ * misbehaving adapter is wrong).
84
+ *
85
+ * Retries only ever follow typed retryable errors, so a live smoke never
86
+ * converts a real adapter failure into a pass and never spends more than
87
+ * `attempts` calls.
88
+ */
89
+ declare function runLiveSmoke(adapter: Pick<ProviderAdapter, "stream">, req: ChatRequest, options?: RunLiveSmokeOptions): Promise<LiveSmokeOutcome>;
90
+ //#endregion
30
91
  //#region src/cassettes/build-fixtures.d.ts
31
92
  /** One cassette fixture file: id, provenance note, and the journal. */
32
93
  /** @internal */
@@ -152,4 +213,4 @@ declare function replay(options: {
152
213
  adapters?: ProviderAdapter[];
153
214
  }): ProviderAdapter[];
154
215
  //#endregion
155
- export { type CassetteFixture, type CreateTestEngineOptions, FAKE_MODEL, FAKE_MODEL_REF, FakeAdapter, type FakeAdapterOptions, type FakeCall, type FakeResponder, type FakeToolCallsValue, type FakeWireErrorValue, M6_ORCH_GOAL, M6_ORCH_PROFILES, M6_ORCH_RUN_ID, RedactFn, type ReplayRunOptions, type TestEngine, type TestRunHandle, VcrCassette, VcrMissError, VcrRow, buildFrozenV1JournalRaw, buildM2CassetteFixtures, buildV2GoldenIdentity, createTestEngine, defaultRedact, fakeToolCalls, fakeWireError, handlesInRequest, normalizeM6Entries, readCassette, record, recordLiveCassettes, recordOrchestratorCrash, replay, replayRun, requestHash };
216
+ export { type CassetteFixture, type CreateTestEngineOptions, FAKE_MODEL, FAKE_MODEL_REF, FakeAdapter, type FakeAdapterOptions, type FakeCall, type FakeResponder, type FakeToolCallsValue, type FakeWireErrorValue, type LiveSmokeOutcome, M6_ORCH_GOAL, M6_ORCH_PROFILES, M6_ORCH_RUN_ID, RedactFn, type ReplayRunOptions, type RunLiveSmokeOptions, type TestEngine, type TestRunHandle, VcrCassette, VcrMissError, VcrRow, buildFrozenV1JournalRaw, buildM2CassetteFixtures, buildV2GoldenIdentity, createTestEngine, defaultRedact, fakeToolCalls, fakeWireError, handlesInRequest, liveTestEnabled, normalizeM6Entries, readCassette, record, recordLiveCassettes, recordOrchestratorCrash, replay, replayRun, requestHash, runLiveSmoke };
package/dist/index.js CHANGED
@@ -7,7 +7,10 @@ import { createHash } from "node:crypto";
7
7
  * FakeAdapter (M1-T14): a REAL ProviderAdapter that resolves calls from
8
8
  * declared patterns instead of the network, behind the same seam as live
9
9
  * adapters, so unit tests run through the full engine: journal, scheduler,
10
- * budget layers, and event stream. Calls cost zero USD.
10
+ * budget layers, and event stream. Calls cost zero USD. Honors the
11
+ * caller's AbortSignal exactly like a live adapter: an abort ends the
12
+ * stream promptly with no terminal event, so cancellation, deadline, and
13
+ * budget tests observe the same journal shapes as production adapters.
11
14
  */
12
15
  /** Scripts a tool-calling turn from a responder. */
13
16
  function fakeToolCalls(...calls) {
@@ -49,6 +52,38 @@ const FAKE_CAPS = {
49
52
  outputUsdPerMTok: 0
50
53
  }
51
54
  };
55
+ /**
56
+ * Races a responder promise against the caller's abort. On abort the
57
+ * pending responder is detached: its eventual value is discarded and an
58
+ * eventual rejection is swallowed (the caller cancelled; a late failure
59
+ * of the abandoned work must not become an unhandled rejection). A
60
+ * rejection that settles first propagates to the caller unchanged.
61
+ */
62
+ async function raceAbort(pending, signal) {
63
+ if (signal === void 0) return {
64
+ aborted: false,
65
+ value: await pending
66
+ };
67
+ if (signal.aborted) {
68
+ pending.catch(() => void 0);
69
+ return { aborted: true };
70
+ }
71
+ let onAbort = () => void 0;
72
+ const abortPromise = new Promise((resolve) => {
73
+ onAbort = () => resolve({ aborted: true });
74
+ signal.addEventListener("abort", onAbort, { once: true });
75
+ });
76
+ try {
77
+ const raced = await Promise.race([pending.then((value) => ({
78
+ aborted: false,
79
+ value
80
+ })), abortPromise]);
81
+ if (raced.aborted) pending.catch(() => void 0);
82
+ return raced;
83
+ } finally {
84
+ signal.removeEventListener("abort", onAbort);
85
+ }
86
+ }
52
87
  function lastUserText(req) {
53
88
  for (let i = req.messages.length - 1; i >= 0; i -= 1) {
54
89
  const msg = req.messages[i];
@@ -61,7 +96,10 @@ var FakeAdapter = class {
61
96
  id = "fake";
62
97
  agents;
63
98
  mintId = createCanonicalIdMinter();
64
- /** Every request this adapter served, in order. */
99
+ /**
100
+ * Every request this adapter served, in order. A request whose signal
101
+ * was already aborted on arrival was never served and is not recorded.
102
+ */
65
103
  calls = [];
66
104
  constructor(options) {
67
105
  this.agents = options.agents;
@@ -83,7 +121,9 @@ var FakeAdapter = class {
83
121
  }
84
122
  return fallback;
85
123
  }
86
- async *stream(req) {
124
+ async *stream(req, signal) {
125
+ const aborted = () => signal?.aborted === true;
126
+ if (aborted()) return;
87
127
  const telemetry = req.providerOptions?.rulvar ?? {};
88
128
  const call = {
89
129
  prompt: lastUserText(req),
@@ -107,8 +147,13 @@ var FakeAdapter = class {
107
147
  }
108
148
  let value;
109
149
  try {
110
- value = typeof responder === "function" ? await responder(call) : responder;
150
+ if (typeof responder === "function") {
151
+ const raced = await raceAbort(Promise.resolve(responder(call)), signal);
152
+ if (raced.aborted) return;
153
+ value = raced.value;
154
+ } else value = responder;
111
155
  } catch (thrown) {
156
+ if (aborted()) return;
112
157
  yield {
113
158
  type: "error",
114
159
  error: {
@@ -120,14 +165,12 @@ var FakeAdapter = class {
120
165
  };
121
166
  return;
122
167
  }
123
- if (isFakeWireError(value)) {
124
- yield {
125
- type: "error",
126
- error: value.error
127
- };
128
- return;
129
- }
130
- if (isFakeToolCalls(value)) {
168
+ const events = [];
169
+ if (isFakeWireError(value)) events.push({
170
+ type: "error",
171
+ error: value.error
172
+ });
173
+ else if (isFakeToolCalls(value)) {
131
174
  const usage = {
132
175
  inputTokens: Math.max(1, Math.ceil(call.prompt.length / 4)),
133
176
  outputTokens: Math.max(1, value.calls.length * 8),
@@ -136,66 +179,70 @@ var FakeAdapter = class {
136
179
  };
137
180
  for (const toolCall of value.calls) {
138
181
  const id = this.mintId();
139
- yield {
182
+ events.push({
140
183
  type: "tool-call-start",
141
184
  id,
142
185
  name: toolCall.name
143
- };
144
- yield {
186
+ });
187
+ events.push({
145
188
  type: "tool-call-end",
146
189
  id,
147
190
  args: toolCall.args
148
- };
191
+ });
149
192
  }
150
- yield {
193
+ events.push({
151
194
  type: "finish",
152
195
  finish: { reason: "tool-calls" },
153
196
  usage
197
+ });
198
+ } else {
199
+ const text = typeof value === "string" ? value : JSON.stringify(value);
200
+ const usage = {
201
+ inputTokens: Math.max(1, Math.ceil(call.prompt.length / 4)),
202
+ outputTokens: Math.max(1, Math.ceil(text.length / 4)),
203
+ cacheReadTokens: 0,
204
+ cacheWriteTokens: 0
154
205
  };
155
- return;
156
- }
157
- const text = typeof value === "string" ? value : JSON.stringify(value);
158
- const usage = {
159
- inputTokens: Math.max(1, Math.ceil(call.prompt.length / 4)),
160
- outputTokens: Math.max(1, Math.ceil(text.length / 4)),
161
- cacheReadTokens: 0,
162
- cacheWriteTokens: 0
163
- };
164
- const forcedName = typeof req.toolChoice === "object" ? req.toolChoice.name : void 0;
165
- if (forcedName !== void 0) {
166
- let args = value;
167
- if (typeof value === "string") try {
168
- args = JSON.parse(value);
169
- } catch {
170
- args = { text: value };
206
+ const forcedName = typeof req.toolChoice === "object" ? req.toolChoice.name : void 0;
207
+ if (forcedName !== void 0) {
208
+ let args = value;
209
+ if (typeof value === "string") try {
210
+ args = JSON.parse(value);
211
+ } catch {
212
+ args = { text: value };
213
+ }
214
+ const id = this.mintId();
215
+ events.push({
216
+ type: "tool-call-start",
217
+ id,
218
+ name: forcedName
219
+ });
220
+ events.push({
221
+ type: "tool-call-end",
222
+ id,
223
+ args
224
+ });
225
+ events.push({
226
+ type: "finish",
227
+ finish: { reason: "tool-calls" },
228
+ usage
229
+ });
230
+ } else {
231
+ events.push({
232
+ type: "text-delta",
233
+ text
234
+ });
235
+ events.push({
236
+ type: "finish",
237
+ finish: { reason: "stop" },
238
+ usage
239
+ });
171
240
  }
172
- const id = this.mintId();
173
- yield {
174
- type: "tool-call-start",
175
- id,
176
- name: forcedName
177
- };
178
- yield {
179
- type: "tool-call-end",
180
- id,
181
- args
182
- };
183
- yield {
184
- type: "finish",
185
- finish: { reason: "tool-calls" },
186
- usage
187
- };
188
- return;
189
241
  }
190
- yield {
191
- type: "text-delta",
192
- text
193
- };
194
- yield {
195
- type: "finish",
196
- finish: { reason: "stop" },
197
- usage
198
- };
242
+ for (const event of events) {
243
+ if (aborted()) return;
244
+ yield event;
245
+ }
199
246
  }
200
247
  };
201
248
  //#endregion
@@ -306,6 +353,78 @@ async function replayRun(wf, args, options) {
306
353
  };
307
354
  }
308
355
  //#endregion
356
+ //#region src/live.ts
357
+ /**
358
+ * True only when `RULVAR_LIVE_TESTS` is exactly `'1'` AND every named
359
+ * environment key is set to a non-empty value. Gate live tests as
360
+ * `it.skipIf(!liveTestEnabled('ANTHROPIC_API_KEY'))(...)` so an
361
+ * unrelated key in the shell never triggers a paid provider call from
362
+ * an ordinary test run.
363
+ */
364
+ function liveTestEnabled(...requiredEnvKeys) {
365
+ if (process.env.RULVAR_LIVE_TESTS !== "1") return false;
366
+ return requiredEnvKeys.every((key) => {
367
+ const value = process.env[key];
368
+ return value !== void 0 && value !== "";
369
+ });
370
+ }
371
+ /**
372
+ * Drains `adapter.stream(req)` with a bounded retry policy and classifies
373
+ * the outcome instead of throwing:
374
+ *
375
+ * - `'ok'`: a `finish` event arrived (the events of the successful
376
+ * attempt are included for further assertions).
377
+ * - `'failed'`: a terminal error with `retryable: false`; never retried,
378
+ * diagnostics preserved.
379
+ * - `'exhausted'`: every attempt ended in a `retryable: true` error; the
380
+ * per-attempt errors are preserved in order.
381
+ * - `'no-terminal'`: the stream ended with neither `finish` nor `error`,
382
+ * which violates the provider SPI; never retried (spending again on a
383
+ * misbehaving adapter is wrong).
384
+ *
385
+ * Retries only ever follow typed retryable errors, so a live smoke never
386
+ * converts a real adapter failure into a pass and never spends more than
387
+ * `attempts` calls.
388
+ */
389
+ async function runLiveSmoke(adapter, req, options) {
390
+ const attempts = Math.max(1, options?.attempts ?? 3);
391
+ const baseDelayMs = options?.baseDelayMs ?? 2e3;
392
+ const retryableErrors = [];
393
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
394
+ const events = [];
395
+ for await (const event of adapter.stream(req)) events.push(event);
396
+ if (events.some((event) => event.type === "finish")) return {
397
+ status: "ok",
398
+ attempts: attempt,
399
+ events
400
+ };
401
+ const errorEvent = events.find((event) => event.type === "error");
402
+ if (errorEvent === void 0) return {
403
+ status: "no-terminal",
404
+ attempts: attempt,
405
+ events
406
+ };
407
+ if (!errorEvent.error.retryable) return {
408
+ status: "failed",
409
+ attempts: attempt,
410
+ error: errorEvent.error,
411
+ events
412
+ };
413
+ retryableErrors.push(errorEvent.error);
414
+ if (attempt < attempts && baseDelayMs > 0) await delay(baseDelayMs * attempt);
415
+ }
416
+ return {
417
+ status: "exhausted",
418
+ attempts,
419
+ errors: retryableErrors
420
+ };
421
+ }
422
+ function delay(ms) {
423
+ return new Promise((resolve) => {
424
+ setTimeout(resolve, ms);
425
+ });
426
+ }
427
+ //#endregion
309
428
  //#region src/cassettes/build-fixtures.ts
310
429
  /**
311
430
  * M2 cassette and frozen-fixture builders (M2-T12). Fixtures are
@@ -1970,4 +2089,4 @@ function replay(options) {
1970
2089
  });
1971
2090
  }
1972
2091
  //#endregion
1973
- export { FAKE_MODEL, FAKE_MODEL_REF, FakeAdapter, M6_ORCH_GOAL, M6_ORCH_PROFILES, M6_ORCH_RUN_ID, VcrMissError, buildFrozenV1JournalRaw, buildM2CassetteFixtures, buildV2GoldenIdentity, createTestEngine, defaultRedact, fakeToolCalls, fakeWireError, handlesInRequest, normalizeM6Entries, readCassette, record, recordLiveCassettes, recordOrchestratorCrash, replay, replayRun, requestHash };
2092
+ export { FAKE_MODEL, FAKE_MODEL_REF, FakeAdapter, M6_ORCH_GOAL, M6_ORCH_PROFILES, M6_ORCH_RUN_ID, VcrMissError, buildFrozenV1JournalRaw, buildM2CassetteFixtures, buildV2GoldenIdentity, createTestEngine, defaultRedact, fakeToolCalls, fakeWireError, handlesInRequest, liveTestEnabled, normalizeM6Entries, readCassette, record, recordLiveCassettes, recordOrchestratorCrash, replay, replayRun, requestHash, runLiveSmoke };
@@ -1,4 +1,4 @@
1
- import { r as TestRunHandle } from "./test-engine-2Md1gvT4.js";
1
+ import { r as TestRunHandle } from "./test-engine-DAXwdoOQ.js";
2
2
 
3
3
  //#region src/matchers.d.ts
4
4
  interface MatcherResult {
@@ -48,12 +48,15 @@ declare class FakeAdapter implements ProviderAdapter {
48
48
  readonly id = "fake";
49
49
  private readonly agents;
50
50
  private readonly mintId;
51
- /** Every request this adapter served, in order. */
51
+ /**
52
+ * Every request this adapter served, in order. A request whose signal
53
+ * was already aborted on arrival was never served and is not recorded.
54
+ */
52
55
  readonly calls: FakeCall[];
53
56
  constructor(options: FakeAdapterOptions);
54
57
  caps(this: void): ModelCaps;
55
58
  private match;
56
- stream(req: ChatRequest): AsyncIterable<ChatEvent>;
59
+ stream(req: ChatRequest, signal?: AbortSignal): AsyncIterable<ChatEvent>;
57
60
  }
58
61
  //#endregion
59
62
  //#region src/test-engine.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/testing",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Rulvar test harness: createTestEngine, FakeAdapter, VCR cassettes, replay-strict runs, matchers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -26,7 +26,7 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@rulvar/core": "1.12.0"
29
+ "@rulvar/core": "1.14.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.20.0",