@rulvar/testing 1.31.0 → 1.33.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 +62 -11
- package/dist/index.js +167 -59
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -140,6 +140,23 @@ interface VcrRow {
|
|
|
140
140
|
* unstamped entry reads as recorded before the stamp existed).
|
|
141
141
|
*/
|
|
142
142
|
usageSemantics?: string;
|
|
143
|
+
/**
|
|
144
|
+
* Zero based per `(adapterId, requestHash)` call counter, claimed
|
|
145
|
+
* synchronously when the recorded `stream()` call was made
|
|
146
|
+
* (v1.31.0 review P2): rows are appended in COMPLETION order, so
|
|
147
|
+
* without this number two concurrent identical live calls that
|
|
148
|
+
* finish out of order would swap callers at replay, which hands
|
|
149
|
+
* occurrences out in caller order. Replay sorts same hash rows by
|
|
150
|
+
* it when every row of the group carries one; absent in cassettes
|
|
151
|
+
* recorded before v1.32.0, whose same hash rows keep file order.
|
|
152
|
+
* An aborted or failed call claims a number but appends no row, so
|
|
153
|
+
* gaps in the numbering are valid. An appending `record()` session
|
|
154
|
+
* seeds its counters past the numbers already on disk, so the
|
|
155
|
+
* numbering continues across sequential sessions; a duplicate
|
|
156
|
+
* number inside a fully numbered group refuses replay as ambiguous
|
|
157
|
+
* (v1.32.0 review P2).
|
|
158
|
+
*/
|
|
159
|
+
occurrence?: number;
|
|
143
160
|
requestHash: string;
|
|
144
161
|
/** Redacted canonical request, for humans and drift review. */
|
|
145
162
|
request: unknown;
|
|
@@ -177,8 +194,24 @@ declare function requestHash(req: ChatRequest): string;
|
|
|
177
194
|
* (a requested abort or a truncated read), throws, or violates the
|
|
178
195
|
* adapter contract (a second terminal, data after the terminal)
|
|
179
196
|
* appends nothing, so a cassette row is always the record of one
|
|
180
|
-
* completed exchange (v1.28.0 review P2).
|
|
181
|
-
*
|
|
197
|
+
* completed exchange (v1.28.0 review P2). Every call also claims a
|
|
198
|
+
* per `(adapterId, requestHash)` occurrence number synchronously in
|
|
199
|
+
* the `stream()` call itself and persists it on the completed row,
|
|
200
|
+
* so replay can restore the caller to response association even when
|
|
201
|
+
* concurrent identical calls completed out of order (v1.31.0 review
|
|
202
|
+
* P2). A later `record()` call on the same cassette file is an
|
|
203
|
+
* appending session: the existing file is read and validated first
|
|
204
|
+
* (a target that was never a cassette, a header whose hashVersion is
|
|
205
|
+
* not the one this build records under, and a file whose occurrence
|
|
206
|
+
* numbering is already ambiguous all refuse with a typed
|
|
207
|
+
* ConfigError), and every hash counter is seeded past the numbers
|
|
208
|
+
* already on disk, so the numbering continues where the file left
|
|
209
|
+
* off instead of restarting at zero (v1.32.0 review P2). One
|
|
210
|
+
* recorder session may be active on a cassette at a time: two
|
|
211
|
+
* concurrently constructed recorders seed identically and claim
|
|
212
|
+
* colliding numbers, which replay refuses as ambiguous instead of
|
|
213
|
+
* silently serving either order. The wrapped adapters are drop-in:
|
|
214
|
+
* same ids, providers, caps, and event streams.
|
|
182
215
|
*/
|
|
183
216
|
declare function record(options: {
|
|
184
217
|
adapters: ProviderAdapter[];
|
|
@@ -189,8 +222,9 @@ declare function record(options: {
|
|
|
189
222
|
* Typed hermetic-miss error; onMiss: 'throw' raises it on any request
|
|
190
223
|
* without a servable row. `recordedOccurrences` above zero means the
|
|
191
224
|
* hash WAS recorded but every occurrence is already consumed (replay
|
|
192
|
-
* serves each recorded exchange once, in
|
|
193
|
-
* means the request was never recorded at all (v1.29.0 review
|
|
225
|
+
* serves each recorded exchange once, in recorded order); absent or
|
|
226
|
+
* zero means the request was never recorded at all (v1.29.0 review
|
|
227
|
+
* P2).
|
|
194
228
|
*/
|
|
195
229
|
declare class VcrMissError extends Error {
|
|
196
230
|
readonly requestHash: string;
|
|
@@ -211,7 +245,8 @@ interface VcrCassette {
|
|
|
211
245
|
* instead of being read as v1. Every documented header field (kind,
|
|
212
246
|
* v, an integer hashVersion, a date string recordedAt) and row field
|
|
213
247
|
* (adapterId, model, requestHash, request, caps, events, an optional
|
|
214
|
-
* string provider, an optional nonempty usageSemantics
|
|
248
|
+
* string provider, an optional nonempty usageSemantics, an optional
|
|
249
|
+
* nonnegative integer occurrence) is checked
|
|
215
250
|
* here, and the nested structures are validated in depth (v1.30.0
|
|
216
251
|
* review P3): the request must be a plain object, every event must
|
|
217
252
|
* be a member of the canonical ChatEvent vocabulary with its
|
|
@@ -232,11 +267,21 @@ declare function readCassette(path: string): VcrCassette;
|
|
|
232
267
|
* hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
|
|
233
268
|
* matching live adapter in `adapters` (a development convenience only).
|
|
234
269
|
*
|
|
235
|
-
* Repeated hashes replay
|
|
236
|
-
* sharing a `(adapterId, requestHash)` key form an ordered
|
|
237
|
-
* list, and every `stream()` call consumes exactly one
|
|
238
|
-
* allocated synchronously inside the call itself, so two
|
|
239
|
-
* identical requests can never be served the same
|
|
270
|
+
* Repeated hashes replay as ordered occurrences (v1.29.0 review P2):
|
|
271
|
+
* rows sharing a `(adapterId, requestHash)` key form an ordered
|
|
272
|
+
* occurrence list, and every `stream()` call consumes exactly one
|
|
273
|
+
* occurrence, allocated synchronously inside the call itself, so two
|
|
274
|
+
* concurrent identical requests can never be served the same
|
|
275
|
+
* recorded exchange. The list is sorted by the recorded `occurrence`
|
|
276
|
+
* numbers when every row of the group carries one, so concurrent
|
|
277
|
+
* identical calls whose live completions were appended out of order
|
|
278
|
+
* still replay to the callers that made them (v1.31.0 review P2); a
|
|
279
|
+
* group with any unnumbered row (recorded before v1.32.0) keeps file
|
|
280
|
+
* order. A duplicate occurrence inside a fully numbered group
|
|
281
|
+
* refuses the whole cassette with a typed ConfigError naming the
|
|
282
|
+
* adapter and hash: it means two recorder sessions wrote the file
|
|
283
|
+
* concurrently, and serving either order would hand a caller the
|
|
284
|
+
* wrong exchange (v1.32.0 review P2).
|
|
240
285
|
* A call after the last occurrence is a miss: under `onMiss: 'throw'`
|
|
241
286
|
* it raises a VcrMissError whose `recordedOccurrences` says the hash
|
|
242
287
|
* WAS recorded but is exhausted, and under `'passthrough'` it
|
|
@@ -257,7 +302,13 @@ declare function readCassette(path: string): VcrCassette;
|
|
|
257
302
|
* both declarations; a conflict refuses with a typed ConfigError
|
|
258
303
|
* before anything is served. A cassette recorded before v1.31.0
|
|
259
304
|
* stores no usageSemantics, so its replays stamp nothing (documented
|
|
260
|
-
* historical laxity).
|
|
305
|
+
* historical laxity). Under `onMiss: 'passthrough'` the recorded
|
|
306
|
+
* declarations must also match the live adapter's, absent versus
|
|
307
|
+
* present included, because a live served miss is journaled under
|
|
308
|
+
* the wrapper's declarations; a mismatch refuses at construction
|
|
309
|
+
* (v1.31.0 review P2). An adapter with no recorded rows keeps the
|
|
310
|
+
* live adapter's own declarations, so the wrapper stays a metadata
|
|
311
|
+
* preserving drop in.
|
|
261
312
|
*/
|
|
262
313
|
declare function replay(options: {
|
|
263
314
|
cassette: string;
|
package/dist/index.js
CHANGED
|
@@ -330,69 +330,144 @@ function headerLine() {
|
|
|
330
330
|
});
|
|
331
331
|
}
|
|
332
332
|
/**
|
|
333
|
+
* Groups rows by `(adapterId, requestHash)` and orders every fully
|
|
334
|
+
* numbered group by its recorded occurrence numbers. Same hash rows
|
|
335
|
+
* sit in the file in COMPLETION order; when every row of a group
|
|
336
|
+
* carries the occurrence number claimed at stream call time, the
|
|
337
|
+
* group is served in that order instead, so concurrent identical
|
|
338
|
+
* calls that finished out of order still replay to the callers that
|
|
339
|
+
* made them (v1.31.0 review P2). A group with any unnumbered row
|
|
340
|
+
* (recorded before v1.32.0) keeps file order, and gaps in the
|
|
341
|
+
* numbering (an aborted or failed call claims a number but appends
|
|
342
|
+
* no row) are valid. A DUPLICATE number inside a fully numbered
|
|
343
|
+
* group refuses the whole cassette: it means two recorder sessions
|
|
344
|
+
* wrote the file concurrently (the documented contract is one active
|
|
345
|
+
* recorder per cassette), and serving either order would silently
|
|
346
|
+
* hand a caller the wrong exchange (v1.32.0 review P2). Both replay
|
|
347
|
+
* and an appending record session group through here, so the refusal
|
|
348
|
+
* fires before anything is served or appended.
|
|
349
|
+
*/
|
|
350
|
+
function groupRows(rows, cassette) {
|
|
351
|
+
const byAdapter = /* @__PURE__ */ new Map();
|
|
352
|
+
for (const row of rows) {
|
|
353
|
+
const forAdapter = byAdapter.get(row.adapterId) ?? /* @__PURE__ */ new Map();
|
|
354
|
+
const occurrences = forAdapter.get(row.requestHash) ?? [];
|
|
355
|
+
occurrences.push(row);
|
|
356
|
+
forAdapter.set(row.requestHash, occurrences);
|
|
357
|
+
byAdapter.set(row.adapterId, forAdapter);
|
|
358
|
+
}
|
|
359
|
+
for (const [adapterId, forAdapter] of byAdapter) for (const [hash, occurrences] of forAdapter) {
|
|
360
|
+
if (!occurrences.every((row) => row.occurrence !== void 0)) continue;
|
|
361
|
+
occurrences.sort((a, b) => (a.occurrence ?? 0) - (b.occurrence ?? 0));
|
|
362
|
+
for (let index = 1; index < occurrences.length; index += 1) {
|
|
363
|
+
const number = occurrences[index]?.occurrence;
|
|
364
|
+
if (number !== void 0 && number === occurrences[index - 1]?.occurrence) throw new ConfigError(`${cassette} records occurrence ${String(number)} twice for adapter '${adapterId}' hash ${hash.slice(0, 12)}; two recorder sessions likely wrote this cassette concurrently, so the replay order would be ambiguous; record the cassette again`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return byAdapter;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
333
370
|
* Wraps live adapters for recording: every stream that completes with
|
|
334
371
|
* exactly one terminal event (finish or error) appends one redacted
|
|
335
372
|
* row to the cassette JSONL. A stream that ends without a terminal
|
|
336
373
|
* (a requested abort or a truncated read), throws, or violates the
|
|
337
374
|
* adapter contract (a second terminal, data after the terminal)
|
|
338
375
|
* appends nothing, so a cassette row is always the record of one
|
|
339
|
-
* completed exchange (v1.28.0 review P2).
|
|
340
|
-
*
|
|
376
|
+
* completed exchange (v1.28.0 review P2). Every call also claims a
|
|
377
|
+
* per `(adapterId, requestHash)` occurrence number synchronously in
|
|
378
|
+
* the `stream()` call itself and persists it on the completed row,
|
|
379
|
+
* so replay can restore the caller to response association even when
|
|
380
|
+
* concurrent identical calls completed out of order (v1.31.0 review
|
|
381
|
+
* P2). A later `record()` call on the same cassette file is an
|
|
382
|
+
* appending session: the existing file is read and validated first
|
|
383
|
+
* (a target that was never a cassette, a header whose hashVersion is
|
|
384
|
+
* not the one this build records under, and a file whose occurrence
|
|
385
|
+
* numbering is already ambiguous all refuse with a typed
|
|
386
|
+
* ConfigError), and every hash counter is seeded past the numbers
|
|
387
|
+
* already on disk, so the numbering continues where the file left
|
|
388
|
+
* off instead of restarting at zero (v1.32.0 review P2). One
|
|
389
|
+
* recorder session may be active on a cassette at a time: two
|
|
390
|
+
* concurrently constructed recorders seed identically and claim
|
|
391
|
+
* colliding numbers, which replay refuses as ambiguous instead of
|
|
392
|
+
* silently serving either order. The wrapped adapters are drop-in:
|
|
393
|
+
* same ids, providers, caps, and event streams.
|
|
341
394
|
*/
|
|
342
395
|
function record(options) {
|
|
343
396
|
const redact = options.redact ? (value) => defaultRedact(options.redact ? options.redact(value) : value) : defaultRedact;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
let terminals = 0;
|
|
354
|
-
let postTerminal = false;
|
|
355
|
-
try {
|
|
356
|
-
for await (const event of adapter.stream(req, signal)) {
|
|
357
|
-
if (terminals > 0) postTerminal = true;
|
|
358
|
-
if (isTerminalEvent(event)) terminals += 1;
|
|
359
|
-
events.push(event);
|
|
360
|
-
yield event;
|
|
361
|
-
}
|
|
362
|
-
} catch (error) {
|
|
363
|
-
thrown = true;
|
|
364
|
-
throw error;
|
|
365
|
-
} finally {
|
|
366
|
-
if (!thrown && terminals === 1 && !postTerminal) {
|
|
367
|
-
const row = {
|
|
368
|
-
adapterId: adapter.id,
|
|
369
|
-
...adapter.provider === void 0 ? {} : { provider: adapter.provider },
|
|
370
|
-
...adapter.usageSemantics === void 0 ? {} : { usageSemantics: adapter.usageSemantics },
|
|
371
|
-
requestHash: requestHash(req),
|
|
372
|
-
request: walkStrings(JSON.parse(JSON.stringify(req)), redact),
|
|
373
|
-
events: walkStrings(JSON.parse(JSON.stringify(events)), redact),
|
|
374
|
-
caps: adapter.caps(req.model),
|
|
375
|
-
model: req.model
|
|
376
|
-
};
|
|
377
|
-
appendFileSync(options.cassette, `${JSON.stringify(row)}\n`, "utf8");
|
|
378
|
-
}
|
|
397
|
+
const seeds = /* @__PURE__ */ new Map();
|
|
398
|
+
if (existsSync(options.cassette)) {
|
|
399
|
+
const existing = readCassette(options.cassette);
|
|
400
|
+
if (existing.header.hashVersion !== CURRENT_HASH_VERSION) throw new ConfigError(`${options.cassette} was recorded under hashVersion ${String(existing.header.hashVersion)} and this build records under ${String(CURRENT_HASH_VERSION)}; appending would mix two identity profiles under one header, so record the cassette again from scratch`);
|
|
401
|
+
for (const [adapterId, forAdapter] of groupRows(existing.rows, options.cassette)) {
|
|
402
|
+
const forSeeds = /* @__PURE__ */ new Map();
|
|
403
|
+
for (const [hash, rows] of forAdapter) {
|
|
404
|
+
const numbered = rows.map((row) => row.occurrence).filter((value) => value !== void 0);
|
|
405
|
+
forSeeds.set(hash, numbered.length === 0 ? 0 : Math.max(...numbered) + 1);
|
|
379
406
|
}
|
|
407
|
+
seeds.set(adapterId, forSeeds);
|
|
380
408
|
}
|
|
381
|
-
}));
|
|
409
|
+
} else writeFileSync(options.cassette, `${headerLine()}\n`, "utf8");
|
|
410
|
+
return options.adapters.map((adapter) => {
|
|
411
|
+
const occurrences = new Map(seeds.get(adapter.id) ?? []);
|
|
412
|
+
return {
|
|
413
|
+
...adapter,
|
|
414
|
+
id: adapter.id,
|
|
415
|
+
...adapter.provider === void 0 ? {} : { provider: adapter.provider },
|
|
416
|
+
caps: (model) => adapter.caps(model),
|
|
417
|
+
stream(req, signal) {
|
|
418
|
+
const hash = requestHash(req);
|
|
419
|
+
const occurrence = occurrences.get(hash) ?? 0;
|
|
420
|
+
occurrences.set(hash, occurrence + 1);
|
|
421
|
+
return (async function* () {
|
|
422
|
+
const events = [];
|
|
423
|
+
let thrown = false;
|
|
424
|
+
let terminals = 0;
|
|
425
|
+
let postTerminal = false;
|
|
426
|
+
try {
|
|
427
|
+
for await (const event of adapter.stream(req, signal)) {
|
|
428
|
+
if (terminals > 0) postTerminal = true;
|
|
429
|
+
if (isTerminalEvent(event)) terminals += 1;
|
|
430
|
+
events.push(event);
|
|
431
|
+
yield event;
|
|
432
|
+
}
|
|
433
|
+
} catch (error) {
|
|
434
|
+
thrown = true;
|
|
435
|
+
throw error;
|
|
436
|
+
} finally {
|
|
437
|
+
if (!thrown && terminals === 1 && !postTerminal) {
|
|
438
|
+
const row = {
|
|
439
|
+
adapterId: adapter.id,
|
|
440
|
+
...adapter.provider === void 0 ? {} : { provider: adapter.provider },
|
|
441
|
+
...adapter.usageSemantics === void 0 ? {} : { usageSemantics: adapter.usageSemantics },
|
|
442
|
+
occurrence,
|
|
443
|
+
requestHash: hash,
|
|
444
|
+
request: walkStrings(JSON.parse(JSON.stringify(req)), redact),
|
|
445
|
+
events: walkStrings(JSON.parse(JSON.stringify(events)), redact),
|
|
446
|
+
caps: adapter.caps(req.model),
|
|
447
|
+
model: req.model
|
|
448
|
+
};
|
|
449
|
+
appendFileSync(options.cassette, `${JSON.stringify(row)}\n`, "utf8");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
})();
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
});
|
|
382
456
|
}
|
|
383
457
|
/**
|
|
384
458
|
* Typed hermetic-miss error; onMiss: 'throw' raises it on any request
|
|
385
459
|
* without a servable row. `recordedOccurrences` above zero means the
|
|
386
460
|
* hash WAS recorded but every occurrence is already consumed (replay
|
|
387
|
-
* serves each recorded exchange once, in
|
|
388
|
-
* means the request was never recorded at all (v1.29.0 review
|
|
461
|
+
* serves each recorded exchange once, in recorded order); absent or
|
|
462
|
+
* zero means the request was never recorded at all (v1.29.0 review
|
|
463
|
+
* P2).
|
|
389
464
|
*/
|
|
390
465
|
var VcrMissError = class extends Error {
|
|
391
466
|
requestHash;
|
|
392
467
|
/** Rows recorded for this hash; absent or 0 = never recorded. */
|
|
393
468
|
recordedOccurrences;
|
|
394
469
|
constructor(adapterId, hash, recordedOccurrences) {
|
|
395
|
-
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
|
|
470
|
+
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 recorded order` : `VCR miss: adapter '${adapterId}' received a request with no recorded row (hash ${hash.slice(0, 12)}); onMiss: 'throw' keeps cassette tests hermetic`);
|
|
396
471
|
this.name = "VcrMissError";
|
|
397
472
|
this.requestHash = hash;
|
|
398
473
|
if (recordedOccurrences !== void 0) this.recordedOccurrences = recordedOccurrences;
|
|
@@ -493,7 +568,9 @@ function eventShapeIssue(event, index) {
|
|
|
493
568
|
case "tool-call-delta":
|
|
494
569
|
if (typeof event.id !== "string" || event.id === "") return `${at}.id must be a nonempty string`;
|
|
495
570
|
return typeof event.argsTextDelta === "string" ? void 0 : `${at}.argsTextDelta must be a string`;
|
|
496
|
-
case "tool-call-end":
|
|
571
|
+
case "tool-call-end":
|
|
572
|
+
if (typeof event.id !== "string" || event.id === "") return `${at}.id must be a nonempty string`;
|
|
573
|
+
return Object.hasOwn(event, "args") ? void 0 : `${at}.args must be present (the arguments the call ended with)`;
|
|
497
574
|
case "usage": {
|
|
498
575
|
const usage = event.usage;
|
|
499
576
|
if (!isPlainObject(usage)) return `${at}.usage must be an object`;
|
|
@@ -511,7 +588,20 @@ function eventShapeIssue(event, index) {
|
|
|
511
588
|
if (reason === "refusal") {
|
|
512
589
|
const refusal = finish.refusal;
|
|
513
590
|
if (!isPlainObject(refusal) || typeof refusal.provider !== "string") return `${at}.finish.refusal must be an object naming the provider`;
|
|
591
|
+
const stopDetails = refusal.stopDetails;
|
|
592
|
+
if (stopDetails !== void 0) {
|
|
593
|
+
if (!isPlainObject(stopDetails)) return `${at}.finish.refusal.stopDetails must be an object when present`;
|
|
594
|
+
for (const field of [
|
|
595
|
+
"type",
|
|
596
|
+
"category",
|
|
597
|
+
"explanation"
|
|
598
|
+
]) {
|
|
599
|
+
const value = stopDetails[field];
|
|
600
|
+
if (value !== void 0 && typeof value !== "string") return `${at}.finish.refusal.stopDetails.${field} must be a string when present`;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
514
603
|
}
|
|
604
|
+
if (event.providerMetadata !== void 0 && !isPlainObject(event.providerMetadata)) return `${at}.providerMetadata must be a plain object when present`;
|
|
515
605
|
const usage = event.usage;
|
|
516
606
|
if (!isPlainObject(usage)) return `${at}.usage must be an object (the full Usage of the exchange)`;
|
|
517
607
|
const violations = usageViolations(usage);
|
|
@@ -536,7 +626,8 @@ function eventShapeIssue(event, index) {
|
|
|
536
626
|
* instead of being read as v1. Every documented header field (kind,
|
|
537
627
|
* v, an integer hashVersion, a date string recordedAt) and row field
|
|
538
628
|
* (adapterId, model, requestHash, request, caps, events, an optional
|
|
539
|
-
* string provider, an optional nonempty usageSemantics
|
|
629
|
+
* string provider, an optional nonempty usageSemantics, an optional
|
|
630
|
+
* nonnegative integer occurrence) is checked
|
|
540
631
|
* here, and the nested structures are validated in depth (v1.30.0
|
|
541
632
|
* review P3): the request must be a plain object, every event must
|
|
542
633
|
* be a member of the canonical ChatEvent vocabulary with its
|
|
@@ -583,6 +674,7 @@ function readCassette(path) {
|
|
|
583
674
|
if (typeof row.model !== "string" || row.model === "") reject("model must be a nonempty string");
|
|
584
675
|
if (row.provider !== void 0 && typeof row.provider !== "string") reject("provider, when present, must be a string");
|
|
585
676
|
if (row.usageSemantics !== void 0 && (typeof row.usageSemantics !== "string" || row.usageSemantics === "")) reject("usageSemantics, when present, must be a nonempty string");
|
|
677
|
+
if (row.occurrence !== void 0 && (typeof row.occurrence !== "number" || !Number.isSafeInteger(row.occurrence) || row.occurrence < 0)) reject("occurrence, when present, must be a nonnegative safe integer");
|
|
586
678
|
if (typeof row.request !== "object" || row.request === null || Array.isArray(row.request)) reject("request must be an object (the redacted canonical request)");
|
|
587
679
|
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)");
|
|
588
680
|
const capsIssue = capsShapeIssue(row.caps);
|
|
@@ -602,11 +694,21 @@ function readCassette(path) {
|
|
|
602
694
|
* hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
|
|
603
695
|
* matching live adapter in `adapters` (a development convenience only).
|
|
604
696
|
*
|
|
605
|
-
* Repeated hashes replay
|
|
606
|
-
* sharing a `(adapterId, requestHash)` key form an ordered
|
|
607
|
-
* list, and every `stream()` call consumes exactly one
|
|
608
|
-
* allocated synchronously inside the call itself, so two
|
|
609
|
-
* identical requests can never be served the same
|
|
697
|
+
* Repeated hashes replay as ordered occurrences (v1.29.0 review P2):
|
|
698
|
+
* rows sharing a `(adapterId, requestHash)` key form an ordered
|
|
699
|
+
* occurrence list, and every `stream()` call consumes exactly one
|
|
700
|
+
* occurrence, allocated synchronously inside the call itself, so two
|
|
701
|
+
* concurrent identical requests can never be served the same
|
|
702
|
+
* recorded exchange. The list is sorted by the recorded `occurrence`
|
|
703
|
+
* numbers when every row of the group carries one, so concurrent
|
|
704
|
+
* identical calls whose live completions were appended out of order
|
|
705
|
+
* still replay to the callers that made them (v1.31.0 review P2); a
|
|
706
|
+
* group with any unnumbered row (recorded before v1.32.0) keeps file
|
|
707
|
+
* order. A duplicate occurrence inside a fully numbered group
|
|
708
|
+
* refuses the whole cassette with a typed ConfigError naming the
|
|
709
|
+
* adapter and hash: it means two recorder sessions wrote the file
|
|
710
|
+
* concurrently, and serving either order would hand a caller the
|
|
711
|
+
* wrong exchange (v1.32.0 review P2).
|
|
610
712
|
* A call after the last occurrence is a miss: under `onMiss: 'throw'`
|
|
611
713
|
* it raises a VcrMissError whose `recordedOccurrences` says the hash
|
|
612
714
|
* WAS recorded but is exhausted, and under `'passthrough'` it
|
|
@@ -627,7 +729,13 @@ function readCassette(path) {
|
|
|
627
729
|
* both declarations; a conflict refuses with a typed ConfigError
|
|
628
730
|
* before anything is served. A cassette recorded before v1.31.0
|
|
629
731
|
* stores no usageSemantics, so its replays stamp nothing (documented
|
|
630
|
-
* historical laxity).
|
|
732
|
+
* historical laxity). Under `onMiss: 'passthrough'` the recorded
|
|
733
|
+
* declarations must also match the live adapter's, absent versus
|
|
734
|
+
* present included, because a live served miss is journaled under
|
|
735
|
+
* the wrapper's declarations; a mismatch refuses at construction
|
|
736
|
+
* (v1.31.0 review P2). An adapter with no recorded rows keeps the
|
|
737
|
+
* live adapter's own declarations, so the wrapper stays a metadata
|
|
738
|
+
* preserving drop in.
|
|
631
739
|
*/
|
|
632
740
|
function replay(options) {
|
|
633
741
|
const { header, rows } = readCassette(options.cassette);
|
|
@@ -638,14 +746,7 @@ function replay(options) {
|
|
|
638
746
|
const last = row.events[row.events.length - 1];
|
|
639
747
|
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`);
|
|
640
748
|
});
|
|
641
|
-
const byAdapter =
|
|
642
|
-
for (const row of rows) {
|
|
643
|
-
const forAdapter = byAdapter.get(row.adapterId) ?? /* @__PURE__ */ new Map();
|
|
644
|
-
const occurrences = forAdapter.get(row.requestHash) ?? [];
|
|
645
|
-
occurrences.push(row);
|
|
646
|
-
forAdapter.set(row.requestHash, occurrences);
|
|
647
|
-
byAdapter.set(row.adapterId, forAdapter);
|
|
648
|
-
}
|
|
749
|
+
const byAdapter = groupRows(rows, options.cassette);
|
|
649
750
|
const live = new Map((options.adapters ?? []).map((adapter) => [adapter.id, adapter]));
|
|
650
751
|
return [.../* @__PURE__ */ new Set([...byAdapter.keys(), ...live.keys()])].map((adapterId) => {
|
|
651
752
|
const recorded = byAdapter.get(adapterId) ?? /* @__PURE__ */ new Map();
|
|
@@ -666,11 +767,18 @@ function replay(options) {
|
|
|
666
767
|
if (values.length > 1) throw new ConfigError(`${options.cassette} carries conflicting ${field} values for adapter '${adapterId}' (${values.map((value) => value === void 0 ? "absent" : `'${value}'`).join(", ")}); a replay adapter reports one declaration per adapter, so record the cassette again in one session`);
|
|
667
768
|
}
|
|
668
769
|
const passthrough = live.get(adapterId);
|
|
770
|
+
if (someRow !== void 0 && passthrough !== void 0 && options.onMiss === "passthrough") {
|
|
771
|
+
for (const field of ["provider", "usageSemantics"]) if (someRow[field] !== passthrough[field]) {
|
|
772
|
+
const describe = (value) => value === void 0 ? "absent" : `'${value}'`;
|
|
773
|
+
throw new ConfigError(`${options.cassette} records ${field} ${describe(someRow[field])} for adapter '${adapterId}' but the live passthrough adapter declares ${describe(passthrough[field])}; the engine journals live served misses under the replay adapter declaration, so replay with a matching adapter or record the cassette again`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
const declared = someRow ?? passthrough;
|
|
669
777
|
const cursors = /* @__PURE__ */ new Map();
|
|
670
778
|
return {
|
|
671
779
|
id: adapterId,
|
|
672
|
-
...
|
|
673
|
-
...
|
|
780
|
+
...declared?.provider === void 0 ? {} : { provider: declared.provider },
|
|
781
|
+
...declared?.usageSemantics === void 0 ? {} : { usageSemantics: declared.usageSemantics },
|
|
674
782
|
caps: (model) => {
|
|
675
783
|
const snapshot = capsByModel.get(model)?.caps ?? passthrough?.caps(model);
|
|
676
784
|
if (snapshot === void 0) throw new ConfigError(`VCR replay adapter '${adapterId}' has no caps snapshot for model '${model}'`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/testing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.33.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
|
+
"@rulvar/core": "1.33.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^22.20.0",
|