@rulvar/testing 1.29.0 → 1.30.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
@@ -175,10 +175,18 @@ declare function record(options: {
175
175
  cassette: string;
176
176
  redact?: RedactFn;
177
177
  }): ProviderAdapter[];
178
- /** Typed hermetic-miss error; onMiss: 'throw' raises it on any unrecorded request. */
178
+ /**
179
+ * Typed hermetic-miss error; onMiss: 'throw' raises it on any request
180
+ * without a servable row. `recordedOccurrences` above zero means the
181
+ * hash WAS recorded but every occurrence is already consumed (replay
182
+ * serves each recorded exchange once, in file order); absent or zero
183
+ * means the request was never recorded at all (v1.29.0 review P2).
184
+ */
179
185
  declare class VcrMissError extends Error {
180
186
  readonly requestHash: string;
181
- constructor(adapterId: string, hash: string);
187
+ /** Rows recorded for this hash; absent or 0 = never recorded. */
188
+ readonly recordedOccurrences?: number;
189
+ constructor(adapterId: string, hash: string, recordedOccurrences?: number);
182
190
  }
183
191
  interface VcrCassette {
184
192
  header: VcrHeader;
@@ -187,17 +195,41 @@ interface VcrCassette {
187
195
  /**
188
196
  * Parses a cassette file (one header line plus one JSON row per line).
189
197
  * The header must declare cassette format `v: 1`: the format version
190
- * gates parsing itself, while hashVersion (checked by replay) only
191
- * gates request identity and never substitutes for it, so a future
192
- * incompatible format refuses loudly instead of being read as v1.
193
- * Parse and shape failures throw a typed ConfigError naming the
194
- * cassette path and line (v1.28.0 review P3).
198
+ * gates parsing itself, while hashVersion (whose support window is
199
+ * checked by replay) only gates request identity and never
200
+ * substitutes for it, so a future incompatible format refuses loudly
201
+ * instead of being read as v1. Every documented header field (kind,
202
+ * v, an integer hashVersion, a date-string recordedAt) and row field
203
+ * (adapterId, model, requestHash, request, caps, events, an optional
204
+ * string provider) is shape-checked here; unknown extra fields are
205
+ * tolerated for forward compatibility. Event stream SEMANTICS (one
206
+ * trailing terminal per row) are deliberately not checked at read
207
+ * time; `replay` enforces them before serving anything (v1.29.0
208
+ * review P3). Parse and shape failures throw a typed ConfigError
209
+ * naming the cassette path and line (v1.28.0 review P3).
195
210
  */
196
211
  declare function readCassette(path: string): VcrCassette;
197
212
  /**
198
213
  * Builds replay adapters from a cassette. `onMiss: 'throw'` is the
199
214
  * hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
200
215
  * matching live adapter in `adapters` (a development convenience only).
216
+ *
217
+ * Repeated hashes replay in file order (v1.29.0 review P2): rows
218
+ * sharing a `(adapterId, requestHash)` key form an ordered occurrence
219
+ * list, and every `stream()` call consumes exactly one occurrence,
220
+ * allocated synchronously inside the call itself, so two concurrent
221
+ * identical requests can never be served the same recorded exchange.
222
+ * A call after the last occurrence is a miss: under `onMiss: 'throw'`
223
+ * it raises a VcrMissError whose `recordedOccurrences` says the hash
224
+ * WAS recorded but is exhausted, and under `'passthrough'` it
225
+ * forwards to the live adapter exactly like a never-recorded request.
226
+ *
227
+ * Before serving anything, replay also enforces what `record` has
228
+ * guaranteed since v1.29.0: every row's event stream ends with
229
+ * exactly one terminal event (finish or error), and all caps
230
+ * snapshots for one `(adapterId, model)` agree, since the replay
231
+ * adapter can only report one caps truth per model. Violations throw
232
+ * a typed ConfigError naming the cassette and row.
201
233
  */
202
234
  declare function replay(options: {
203
235
  cassette: string;
package/dist/index.js CHANGED
@@ -317,6 +317,10 @@ function requestHash(req) {
317
317
  };
318
318
  return createHash("sha256").update(canonicalJson(withoutTelemetry), "utf8").digest("hex");
319
319
  }
320
+ /** The terminal vocabulary of the adapter contract: finish or error. */
321
+ function isTerminalEvent(event) {
322
+ return event.type === "finish" || event.type === "error";
323
+ }
320
324
  function headerLine() {
321
325
  return JSON.stringify({
322
326
  v: 1,
@@ -351,7 +355,7 @@ function record(options) {
351
355
  try {
352
356
  for await (const event of adapter.stream(req, signal)) {
353
357
  if (terminals > 0) postTerminal = true;
354
- if (event.type === "finish" || event.type === "error") terminals += 1;
358
+ if (isTerminalEvent(event)) terminals += 1;
355
359
  events.push(event);
356
360
  yield event;
357
361
  }
@@ -375,23 +379,39 @@ function record(options) {
375
379
  }
376
380
  }));
377
381
  }
378
- /** Typed hermetic-miss error; onMiss: 'throw' raises it on any unrecorded request. */
382
+ /**
383
+ * Typed hermetic-miss error; onMiss: 'throw' raises it on any request
384
+ * without a servable row. `recordedOccurrences` above zero means the
385
+ * hash WAS recorded but every occurrence is already consumed (replay
386
+ * serves each recorded exchange once, in file order); absent or zero
387
+ * means the request was never recorded at all (v1.29.0 review P2).
388
+ */
379
389
  var VcrMissError = class extends Error {
380
390
  requestHash;
381
- constructor(adapterId, hash) {
382
- super(`VCR miss: adapter '${adapterId}' received a request with no recorded row (hash ${hash.slice(0, 12)}); onMiss: 'throw' keeps cassette tests hermetic`);
391
+ /** Rows recorded for this hash; absent or 0 = never recorded. */
392
+ recordedOccurrences;
393
+ constructor(adapterId, hash, recordedOccurrences) {
394
+ super(recordedOccurrences !== void 0 && recordedOccurrences > 0 ? `VCR miss: adapter '${adapterId}' exhausted the ${String(recordedOccurrences)} recorded occurrence${recordedOccurrences === 1 ? "" : "s"} of request hash ${hash.slice(0, 12)}; a replay serves each recorded exchange once, in file order` : `VCR miss: adapter '${adapterId}' received a request with no recorded row (hash ${hash.slice(0, 12)}); onMiss: 'throw' keeps cassette tests hermetic`);
383
395
  this.name = "VcrMissError";
384
396
  this.requestHash = hash;
397
+ if (recordedOccurrences !== void 0) this.recordedOccurrences = recordedOccurrences;
385
398
  }
386
399
  };
387
400
  /**
388
401
  * Parses a cassette file (one header line plus one JSON row per line).
389
402
  * The header must declare cassette format `v: 1`: the format version
390
- * gates parsing itself, while hashVersion (checked by replay) only
391
- * gates request identity and never substitutes for it, so a future
392
- * incompatible format refuses loudly instead of being read as v1.
393
- * Parse and shape failures throw a typed ConfigError naming the
394
- * cassette path and line (v1.28.0 review P3).
403
+ * gates parsing itself, while hashVersion (whose support window is
404
+ * checked by replay) only gates request identity and never
405
+ * substitutes for it, so a future incompatible format refuses loudly
406
+ * instead of being read as v1. Every documented header field (kind,
407
+ * v, an integer hashVersion, a date-string recordedAt) and row field
408
+ * (adapterId, model, requestHash, request, caps, events, an optional
409
+ * string provider) is shape-checked here; unknown extra fields are
410
+ * tolerated for forward compatibility. Event stream SEMANTICS (one
411
+ * trailing terminal per row) are deliberately not checked at read
412
+ * time; `replay` enforces them before serving anything (v1.29.0
413
+ * review P3). Parse and shape failures throw a typed ConfigError
414
+ * naming the cassette path and line (v1.28.0 review P3).
395
415
  */
396
416
  function readCassette(path) {
397
417
  const numbered = readFileSync(path, "utf8").split("\n").map((text, index) => ({
@@ -409,11 +429,24 @@ function readCassette(path) {
409
429
  const headerRaw = first === void 0 ? {} : parse(first);
410
430
  if (headerRaw.kind !== "rulvar-vcr") throw new ConfigError(`${path} is not a rulvar VCR cassette`);
411
431
  if (headerRaw.v !== 1) throw new ConfigError(`${path} declares cassette format v ${String(headerRaw.v)}; this build reads format v 1 only, so record the cassette again on a matching engine`);
432
+ if (typeof headerRaw.hashVersion !== "number" || !Number.isSafeInteger(headerRaw.hashVersion)) throw new ConfigError(`${path} header hashVersion must be an integer (the identity profile version every recorded cassette carries); got ${String(headerRaw.hashVersion)}`);
433
+ if (typeof headerRaw.recordedAt !== "string" || Number.isNaN(Date.parse(headerRaw.recordedAt))) throw new ConfigError(`${path} header recordedAt must be a date string; got ${String(headerRaw.recordedAt)}`);
412
434
  return {
413
435
  header: headerRaw,
414
436
  rows: numbered.slice(1).map((line) => {
415
- const row = parse(line);
416
- if (typeof row !== "object" || row === null || typeof row.adapterId !== "string" || typeof row.requestHash !== "string" || !Array.isArray(row.events)) throw new ConfigError(`${path}:${String(line.lineNo)} is not a VCR row (adapterId, requestHash, and events are required)`);
437
+ const parsed = parse(line);
438
+ const reject = (what) => {
439
+ throw new ConfigError(`${path}:${String(line.lineNo)} is not a VCR row: ${what}`);
440
+ };
441
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) reject("each row is a JSON object");
442
+ const row = parsed;
443
+ if (typeof row.adapterId !== "string" || row.adapterId === "") reject("adapterId must be a nonempty string");
444
+ if (typeof row.requestHash !== "string" || row.requestHash === "") reject("requestHash must be a nonempty string");
445
+ if (typeof row.model !== "string" || row.model === "") reject("model must be a nonempty string");
446
+ if (row.provider !== void 0 && typeof row.provider !== "string") reject("provider, when present, must be a string");
447
+ if (typeof row.request !== "object" || row.request === null) reject("request must be an object (the redacted canonical request)");
448
+ if (typeof row.caps !== "object" || row.caps === null || Array.isArray(row.caps)) reject("caps must be an object (the model caps snapshot at record time)");
449
+ if (!Array.isArray(row.events)) reject("events must be an array");
417
450
  return row;
418
451
  })
419
452
  };
@@ -422,44 +455,89 @@ function readCassette(path) {
422
455
  * Builds replay adapters from a cassette. `onMiss: 'throw'` is the
423
456
  * hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
424
457
  * matching live adapter in `adapters` (a development convenience only).
458
+ *
459
+ * Repeated hashes replay in file order (v1.29.0 review P2): rows
460
+ * sharing a `(adapterId, requestHash)` key form an ordered occurrence
461
+ * list, and every `stream()` call consumes exactly one occurrence,
462
+ * allocated synchronously inside the call itself, so two concurrent
463
+ * identical requests can never be served the same recorded exchange.
464
+ * A call after the last occurrence is a miss: under `onMiss: 'throw'`
465
+ * it raises a VcrMissError whose `recordedOccurrences` says the hash
466
+ * WAS recorded but is exhausted, and under `'passthrough'` it
467
+ * forwards to the live adapter exactly like a never-recorded request.
468
+ *
469
+ * Before serving anything, replay also enforces what `record` has
470
+ * guaranteed since v1.29.0: every row's event stream ends with
471
+ * exactly one terminal event (finish or error), and all caps
472
+ * snapshots for one `(adapterId, model)` agree, since the replay
473
+ * adapter can only report one caps truth per model. Violations throw
474
+ * a typed ConfigError naming the cassette and row.
425
475
  */
426
476
  function replay(options) {
427
477
  const { header, rows } = readCassette(options.cassette);
428
478
  const oldestSupported = CURRENT_HASH_VERSION - 1;
429
- if (typeof header.hashVersion !== "number" || header.hashVersion < oldestSupported || header.hashVersion > CURRENT_HASH_VERSION) throw new ConfigError(`${options.cassette} was recorded under hashVersion ${String(header.hashVersion)}, outside the supported window [${oldestSupported}, ${CURRENT_HASH_VERSION}]; record the cassette again on a current engine`);
479
+ if (header.hashVersion < oldestSupported || header.hashVersion > CURRENT_HASH_VERSION) throw new ConfigError(`${options.cassette} was recorded under hashVersion ${String(header.hashVersion)}, outside the supported window [${oldestSupported}, ${CURRENT_HASH_VERSION}]; record the cassette again on a current engine`);
480
+ rows.forEach((row, index) => {
481
+ const terminals = row.events.filter((event) => isTerminalEvent(event)).length;
482
+ const last = row.events[row.events.length - 1];
483
+ if (terminals !== 1 || last === void 0 || !isTerminalEvent(last)) throw new ConfigError(`${options.cassette} row ${String(index + 1)} (adapter '${row.adapterId}', hash ${row.requestHash.slice(0, 12)}) does not record one completed exchange: expected exactly one trailing terminal event (finish or error), found ${String(terminals)}; record the cassette again on a current engine`);
484
+ });
430
485
  const byAdapter = /* @__PURE__ */ new Map();
431
486
  for (const row of rows) {
432
487
  const forAdapter = byAdapter.get(row.adapterId) ?? /* @__PURE__ */ new Map();
433
- forAdapter.set(row.requestHash, row);
488
+ const occurrences = forAdapter.get(row.requestHash) ?? [];
489
+ occurrences.push(row);
490
+ forAdapter.set(row.requestHash, occurrences);
434
491
  byAdapter.set(row.adapterId, forAdapter);
435
492
  }
436
493
  const live = new Map((options.adapters ?? []).map((adapter) => [adapter.id, adapter]));
437
494
  return [.../* @__PURE__ */ new Set([...byAdapter.keys(), ...live.keys()])].map((adapterId) => {
438
495
  const recorded = byAdapter.get(adapterId) ?? /* @__PURE__ */ new Map();
439
- const passthrough = live.get(adapterId);
440
- const someRow = [...recorded.values()][0];
496
+ const recordedRows = [...recorded.values()].flat();
497
+ const someRow = recordedRows[0];
441
498
  const capsByModel = /* @__PURE__ */ new Map();
442
- for (const row of recorded.values()) capsByModel.set(row.model, row.caps);
499
+ for (const row of recordedRows) {
500
+ const canonical = canonicalJson(row.caps);
501
+ const existing = capsByModel.get(row.model);
502
+ if (existing === void 0) capsByModel.set(row.model, {
503
+ caps: row.caps,
504
+ canonical
505
+ });
506
+ else if (existing.canonical !== canonical) throw new ConfigError(`${options.cassette} carries conflicting caps snapshots for adapter '${adapterId}' model '${row.model}'; a replay adapter reports one caps truth per model, so record the cassette again in one session`);
507
+ }
508
+ const passthrough = live.get(adapterId);
509
+ const cursors = /* @__PURE__ */ new Map();
443
510
  return {
444
511
  id: adapterId,
445
512
  ...someRow?.provider === void 0 ? {} : { provider: someRow.provider },
446
513
  caps: (model) => {
447
- const snapshot = capsByModel.get(model) ?? passthrough?.caps(model);
514
+ const snapshot = capsByModel.get(model)?.caps ?? passthrough?.caps(model);
448
515
  if (snapshot === void 0) throw new ConfigError(`VCR replay adapter '${adapterId}' has no caps snapshot for model '${model}'`);
449
516
  return snapshot;
450
517
  },
451
- async *stream(req, signal) {
518
+ stream(req, signal) {
452
519
  const hash = requestHash(req);
453
- const row = recorded.get(hash);
454
- if (row !== void 0) {
455
- for (const event of row.events) yield event;
456
- return;
457
- }
458
- if (options.onMiss === "passthrough" && passthrough !== void 0) {
459
- yield* passthrough.stream(req, signal);
460
- return;
520
+ const occurrences = recorded.get(hash);
521
+ let row;
522
+ if (occurrences !== void 0) {
523
+ const cursor = cursors.get(hash) ?? 0;
524
+ if (cursor < occurrences.length) {
525
+ cursors.set(hash, cursor + 1);
526
+ row = occurrences[cursor];
527
+ }
461
528
  }
462
- throw new VcrMissError(adapterId, hash);
529
+ const recordedCount = occurrences?.length;
530
+ return (async function* () {
531
+ if (row !== void 0) {
532
+ for (const event of row.events) yield event;
533
+ return;
534
+ }
535
+ if (options.onMiss === "passthrough" && passthrough !== void 0) {
536
+ yield* passthrough.stream(req, signal);
537
+ return;
538
+ }
539
+ throw new VcrMissError(adapterId, hash, recordedCount);
540
+ })();
463
541
  }
464
542
  };
465
543
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/testing",
3
- "version": "1.29.0",
3
+ "version": "1.30.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.29.0"
29
+ "@rulvar/core": "1.30.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.20.0",