@rulvar/testing 1.14.0 → 1.16.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
@@ -36,12 +36,36 @@ declare function replayRun<A, R>(wf: Workflow<A, R>, args: A, options: ReplayRun
36
36
  * an ordinary test run.
37
37
  */
38
38
  declare function liveTestEnabled(...requiredEnvKeys: string[]): boolean;
39
+ /** Default total `runLiveSmoke` attempts including the first. */
40
+ declare const DEFAULT_LIVE_SMOKE_ATTEMPTS = 3;
41
+ /**
42
+ * Hard ceiling on `runLiveSmoke` attempts. The helper's whole contract
43
+ * is a bounded spend, so it refuses configurations that are not.
44
+ */
45
+ declare const MAX_LIVE_SMOKE_ATTEMPTS = 10;
46
+ /**
47
+ * Hard ceiling on every scheduled backoff: Node's maximum timer delay
48
+ * (2^31 - 1 ms). Anything above it would not sleep longer, it would be
49
+ * clamped to 1 ms with a TimeoutOverflowWarning, so both `baseDelayMs`
50
+ * and the largest scheduled delay, `baseDelayMs * (attempts - 1)`, are
51
+ * validated against this bound before any stream opens.
52
+ */
53
+ declare const MAX_LIVE_SMOKE_DELAY_MS = 2147483647;
39
54
  interface RunLiveSmokeOptions {
40
- /** Total attempts including the first (default 3, minimum 1). */
55
+ /**
56
+ * Total attempts including the first: an integer from 1 to
57
+ * {@link MAX_LIVE_SMOKE_ATTEMPTS} (default 3). Anything else, NaN and
58
+ * Infinity included, rejects with ConfigError before any stream opens.
59
+ */
41
60
  attempts?: number;
42
61
  /**
43
- * Backoff before retry n (1-based) is `baseDelayMs * n` (default
44
- * 2000). Pass 0 to retry without sleeping (unit tests).
62
+ * Backoff before retry n (1-based) is `baseDelayMs * n`: a
63
+ * non-negative integer (default 2000). Pass 0 to retry without
64
+ * sleeping (unit tests). The value AND the largest scheduled delay,
65
+ * `baseDelayMs * (attempts - 1)`, must not exceed
66
+ * {@link MAX_LIVE_SMOKE_DELAY_MS} (Node's timer maximum, which would
67
+ * otherwise clamp the sleep to 1 ms). Anything else rejects with
68
+ * ConfigError before any stream opens.
45
69
  */
46
70
  baseDelayMs?: number;
47
71
  }
@@ -67,13 +91,18 @@ type LiveSmokeOutcome = {
67
91
  status: "no-terminal";
68
92
  attempts: number;
69
93
  events: ChatEvent[];
94
+ } | {
95
+ status: "contract-violation";
96
+ attempts: number;
97
+ reason: "multiple-terminals" | "terminal-not-final";
98
+ events: ChatEvent[];
70
99
  };
71
100
  /**
72
101
  * Drains `adapter.stream(req)` with a bounded retry policy and classifies
73
102
  * the outcome instead of throwing:
74
103
  *
75
- * - `'ok'`: a `finish` event arrived (the events of the successful
76
- * attempt are included for further assertions).
104
+ * - `'ok'`: the stream ended on a single terminal `finish` (the events of
105
+ * the successful attempt are included for further assertions).
77
106
  * - `'failed'`: a terminal error with `retryable: false`; never retried,
78
107
  * diagnostics preserved.
79
108
  * - `'exhausted'`: every attempt ended in a `retryable: true` error; the
@@ -81,10 +110,18 @@ type LiveSmokeOutcome = {
81
110
  * - `'no-terminal'`: the stream ended with neither `finish` nor `error`,
82
111
  * which violates the provider SPI; never retried (spending again on a
83
112
  * misbehaving adapter is wrong).
113
+ * - `'contract-violation'`: the stream carried more than one terminal
114
+ * event (`'multiple-terminals'`, e.g. an error followed by a finish) or
115
+ * its single terminal was not the final event
116
+ * (`'terminal-not-final'`). Equally an SPI violation, equally never
117
+ * retried, and never reported as a pass.
84
118
  *
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.
119
+ * Retries only ever follow a well-formed stream whose single final
120
+ * terminal is a typed retryable error, so a live smoke never converts a
121
+ * real adapter failure or a malformed stream into a pass and never
122
+ * spends more than `attempts` calls. Options are validated first:
123
+ * invalid `attempts` or `baseDelayMs` reject with ConfigError before any
124
+ * adapter call.
88
125
  */
89
126
  declare function runLiveSmoke(adapter: Pick<ProviderAdapter, "stream">, req: ChatRequest, options?: RunLiveSmokeOptions): Promise<LiveSmokeOutcome>;
90
127
  //#endregion
@@ -213,4 +250,4 @@ declare function replay(options: {
213
250
  adapters?: ProviderAdapter[];
214
251
  }): ProviderAdapter[];
215
252
  //#endregion
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 };
253
+ export { type CassetteFixture, type CreateTestEngineOptions, DEFAULT_LIVE_SMOKE_ATTEMPTS, 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, MAX_LIVE_SMOKE_ATTEMPTS, MAX_LIVE_SMOKE_DELAY_MS, 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
@@ -355,6 +355,27 @@ async function replayRun(wf, args, options) {
355
355
  //#endregion
356
356
  //#region src/live.ts
357
357
  /**
358
+ * Live-test opt-in gate and the bounded live smoke.
359
+ *
360
+ * A provider key in the environment is not an opt-in: key-gated live
361
+ * tests spend provider budget, so they additionally require the explicit
362
+ * RULVAR_LIVE_TESTS=1 switch (the repository's `pnpm test:live` sets it
363
+ * for its child run only). runLiveSmoke drains one adapter stream per
364
+ * attempt and classifies the terminal event: a typed retryable error
365
+ * (429 rate limit, 529 overload, transport) is retried a bounded number
366
+ * of times with linear backoff; a non-retryable error (authentication,
367
+ * invalid model, invalid request) fails immediately with the typed
368
+ * WireError intact. The provider SPI requires exactly one terminal event
369
+ * per stream, as its final event: a stream with no terminal is
370
+ * `'no-terminal'`, one with multiple terminals or a terminal followed by
371
+ * more events is `'contract-violation'`, and neither is ever retried
372
+ * (spending again cannot repair a broken adapter contract). A stream
373
+ * that THROWS propagates unchanged: adapters surface failures as typed
374
+ * error events, so a raw throw is itself a contract violation the caller
375
+ * must see. Options are validated before any stream is opened; invalid
376
+ * values reject with ConfigError instead of being clamped or defaulted.
377
+ */
378
+ /**
358
379
  * True only when `RULVAR_LIVE_TESTS` is exactly `'1'` AND every named
359
380
  * environment key is set to a non-empty value. Gate live tests as
360
381
  * `it.skipIf(!liveTestEnabled('ANTHROPIC_API_KEY'))(...)` so an
@@ -368,12 +389,27 @@ function liveTestEnabled(...requiredEnvKeys) {
368
389
  return value !== void 0 && value !== "";
369
390
  });
370
391
  }
392
+ /** Default total `runLiveSmoke` attempts including the first. */
393
+ const DEFAULT_LIVE_SMOKE_ATTEMPTS = 3;
394
+ /**
395
+ * Hard ceiling on `runLiveSmoke` attempts. The helper's whole contract
396
+ * is a bounded spend, so it refuses configurations that are not.
397
+ */
398
+ const MAX_LIVE_SMOKE_ATTEMPTS = 10;
399
+ /**
400
+ * Hard ceiling on every scheduled backoff: Node's maximum timer delay
401
+ * (2^31 - 1 ms). Anything above it would not sleep longer, it would be
402
+ * clamped to 1 ms with a TimeoutOverflowWarning, so both `baseDelayMs`
403
+ * and the largest scheduled delay, `baseDelayMs * (attempts - 1)`, are
404
+ * validated against this bound before any stream opens.
405
+ */
406
+ const MAX_LIVE_SMOKE_DELAY_MS = 2147483647;
371
407
  /**
372
408
  * Drains `adapter.stream(req)` with a bounded retry policy and classifies
373
409
  * the outcome instead of throwing:
374
410
  *
375
- * - `'ok'`: a `finish` event arrived (the events of the successful
376
- * attempt are included for further assertions).
411
+ * - `'ok'`: the stream ended on a single terminal `finish` (the events of
412
+ * the successful attempt are included for further assertions).
377
413
  * - `'failed'`: a terminal error with `retryable: false`; never retried,
378
414
  * diagnostics preserved.
379
415
  * - `'exhausted'`: every attempt ended in a `retryable: true` error; the
@@ -381,36 +417,57 @@ function liveTestEnabled(...requiredEnvKeys) {
381
417
  * - `'no-terminal'`: the stream ended with neither `finish` nor `error`,
382
418
  * which violates the provider SPI; never retried (spending again on a
383
419
  * misbehaving adapter is wrong).
420
+ * - `'contract-violation'`: the stream carried more than one terminal
421
+ * event (`'multiple-terminals'`, e.g. an error followed by a finish) or
422
+ * its single terminal was not the final event
423
+ * (`'terminal-not-final'`). Equally an SPI violation, equally never
424
+ * retried, and never reported as a pass.
384
425
  *
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.
426
+ * Retries only ever follow a well-formed stream whose single final
427
+ * terminal is a typed retryable error, so a live smoke never converts a
428
+ * real adapter failure or a malformed stream into a pass and never
429
+ * spends more than `attempts` calls. Options are validated first:
430
+ * invalid `attempts` or `baseDelayMs` reject with ConfigError before any
431
+ * adapter call.
388
432
  */
389
433
  async function runLiveSmoke(adapter, req, options) {
390
- const attempts = Math.max(1, options?.attempts ?? 3);
391
- const baseDelayMs = options?.baseDelayMs ?? 2e3;
434
+ const attempts = validatedAttempts(options?.attempts);
435
+ const baseDelayMs = validatedBaseDelayMs(options?.baseDelayMs, attempts);
392
436
  const retryableErrors = [];
393
437
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
394
438
  const events = [];
395
439
  for await (const event of adapter.stream(req)) events.push(event);
396
- if (events.some((event) => event.type === "finish")) return {
397
- status: "ok",
440
+ const terminals = events.filter((event) => event.type === "finish" || event.type === "error");
441
+ const terminal = terminals[0];
442
+ if (terminal === void 0) return {
443
+ status: "no-terminal",
398
444
  attempts: attempt,
399
445
  events
400
446
  };
401
- const errorEvent = events.find((event) => event.type === "error");
402
- if (errorEvent === void 0) return {
403
- status: "no-terminal",
447
+ if (terminals.length > 1) return {
448
+ status: "contract-violation",
404
449
  attempts: attempt,
450
+ reason: "multiple-terminals",
405
451
  events
406
452
  };
407
- if (!errorEvent.error.retryable) return {
453
+ if (terminal !== events.at(-1)) return {
454
+ status: "contract-violation",
455
+ attempts: attempt,
456
+ reason: "terminal-not-final",
457
+ events
458
+ };
459
+ if (terminal.type === "finish") return {
460
+ status: "ok",
461
+ attempts: attempt,
462
+ events
463
+ };
464
+ if (!terminal.error.retryable) return {
408
465
  status: "failed",
409
466
  attempts: attempt,
410
- error: errorEvent.error,
467
+ error: terminal.error,
411
468
  events
412
469
  };
413
- retryableErrors.push(errorEvent.error);
470
+ retryableErrors.push(terminal.error);
414
471
  if (attempt < attempts && baseDelayMs > 0) await delay(baseDelayMs * attempt);
415
472
  }
416
473
  return {
@@ -419,6 +476,30 @@ async function runLiveSmoke(adapter, req, options) {
419
476
  errors: retryableErrors
420
477
  };
421
478
  }
479
+ function validatedAttempts(value) {
480
+ if (value === void 0) return 3;
481
+ if (!Number.isSafeInteger(value) || value < 1 || value > 10) throw new ConfigError(`runLiveSmoke attempts must be an integer from 1 to 10, got ${String(value)}`, { data: {
482
+ field: "attempts",
483
+ value: String(value),
484
+ max: 10
485
+ } });
486
+ return value;
487
+ }
488
+ function validatedBaseDelayMs(value, attempts) {
489
+ if (value === void 0) return 2e3;
490
+ if (!Number.isSafeInteger(value) || value < 0 || value > 2147483647) throw new ConfigError(`runLiveSmoke baseDelayMs must be an integer from 0 to ${MAX_LIVE_SMOKE_DELAY_MS} (Node's timer maximum), got ${String(value)}`, { data: {
491
+ field: "baseDelayMs",
492
+ value: String(value),
493
+ max: MAX_LIVE_SMOKE_DELAY_MS
494
+ } });
495
+ const largestDelayMs = value * (attempts - 1);
496
+ if (largestDelayMs > 2147483647) throw new ConfigError(`runLiveSmoke largest scheduled backoff baseDelayMs * (attempts - 1) = ${String(largestDelayMs)} exceeds ${MAX_LIVE_SMOKE_DELAY_MS} (Node's timer maximum); lower baseDelayMs or attempts`, { data: {
497
+ field: "baseDelayMs * (attempts - 1)",
498
+ value: String(largestDelayMs),
499
+ max: MAX_LIVE_SMOKE_DELAY_MS
500
+ } });
501
+ return value;
502
+ }
422
503
  function delay(ms) {
423
504
  return new Promise((resolve) => {
424
505
  setTimeout(resolve, ms);
@@ -2089,4 +2170,4 @@ function replay(options) {
2089
2170
  });
2090
2171
  }
2091
2172
  //#endregion
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 };
2173
+ export { DEFAULT_LIVE_SMOKE_ATTEMPTS, FAKE_MODEL, FAKE_MODEL_REF, FakeAdapter, M6_ORCH_GOAL, M6_ORCH_PROFILES, M6_ORCH_RUN_ID, MAX_LIVE_SMOKE_ATTEMPTS, MAX_LIVE_SMOKE_DELAY_MS, VcrMissError, buildFrozenV1JournalRaw, buildM2CassetteFixtures, buildV2GoldenIdentity, createTestEngine, defaultRedact, fakeToolCalls, fakeWireError, handlesInRequest, liveTestEnabled, normalizeM6Entries, readCassette, record, recordLiveCassettes, recordOrchestratorCrash, replay, replayRun, requestHash, runLiveSmoke };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/testing",
3
- "version": "1.14.0",
3
+ "version": "1.16.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.14.0"
29
+ "@rulvar/core": "1.16.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.20.0",