@rulvar/testing 1.31.0 → 1.32.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 +43 -11
- package/dist/index.js +103 -51
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -140,6 +140,19 @@ 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.
|
|
154
|
+
*/
|
|
155
|
+
occurrence?: number;
|
|
143
156
|
requestHash: string;
|
|
144
157
|
/** Redacted canonical request, for humans and drift review. */
|
|
145
158
|
request: unknown;
|
|
@@ -177,8 +190,13 @@ declare function requestHash(req: ChatRequest): string;
|
|
|
177
190
|
* (a requested abort or a truncated read), throws, or violates the
|
|
178
191
|
* adapter contract (a second terminal, data after the terminal)
|
|
179
192
|
* appends nothing, so a cassette row is always the record of one
|
|
180
|
-
* completed exchange (v1.28.0 review P2).
|
|
181
|
-
*
|
|
193
|
+
* completed exchange (v1.28.0 review P2). Every call also claims a
|
|
194
|
+
* per `(adapterId, requestHash)` occurrence number synchronously in
|
|
195
|
+
* the `stream()` call itself and persists it on the completed row,
|
|
196
|
+
* so replay can restore the caller to response association even when
|
|
197
|
+
* concurrent identical calls completed out of order (v1.31.0 review
|
|
198
|
+
* P2). The wrapped adapters are drop-in: same ids, providers, caps,
|
|
199
|
+
* and event streams.
|
|
182
200
|
*/
|
|
183
201
|
declare function record(options: {
|
|
184
202
|
adapters: ProviderAdapter[];
|
|
@@ -189,8 +207,9 @@ declare function record(options: {
|
|
|
189
207
|
* Typed hermetic-miss error; onMiss: 'throw' raises it on any request
|
|
190
208
|
* without a servable row. `recordedOccurrences` above zero means the
|
|
191
209
|
* 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
|
|
210
|
+
* serves each recorded exchange once, in recorded order); absent or
|
|
211
|
+
* zero means the request was never recorded at all (v1.29.0 review
|
|
212
|
+
* P2).
|
|
194
213
|
*/
|
|
195
214
|
declare class VcrMissError extends Error {
|
|
196
215
|
readonly requestHash: string;
|
|
@@ -211,7 +230,8 @@ interface VcrCassette {
|
|
|
211
230
|
* instead of being read as v1. Every documented header field (kind,
|
|
212
231
|
* v, an integer hashVersion, a date string recordedAt) and row field
|
|
213
232
|
* (adapterId, model, requestHash, request, caps, events, an optional
|
|
214
|
-
* string provider, an optional nonempty usageSemantics
|
|
233
|
+
* string provider, an optional nonempty usageSemantics, an optional
|
|
234
|
+
* nonnegative integer occurrence) is checked
|
|
215
235
|
* here, and the nested structures are validated in depth (v1.30.0
|
|
216
236
|
* review P3): the request must be a plain object, every event must
|
|
217
237
|
* be a member of the canonical ChatEvent vocabulary with its
|
|
@@ -232,11 +252,17 @@ declare function readCassette(path: string): VcrCassette;
|
|
|
232
252
|
* hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
|
|
233
253
|
* matching live adapter in `adapters` (a development convenience only).
|
|
234
254
|
*
|
|
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
|
|
255
|
+
* Repeated hashes replay as ordered occurrences (v1.29.0 review P2):
|
|
256
|
+
* rows sharing a `(adapterId, requestHash)` key form an ordered
|
|
257
|
+
* occurrence list, and every `stream()` call consumes exactly one
|
|
258
|
+
* occurrence, allocated synchronously inside the call itself, so two
|
|
259
|
+
* concurrent identical requests can never be served the same
|
|
260
|
+
* recorded exchange. The list is sorted by the recorded `occurrence`
|
|
261
|
+
* numbers when every row of the group carries one, so concurrent
|
|
262
|
+
* identical calls whose live completions were appended out of order
|
|
263
|
+
* still replay to the callers that made them (v1.31.0 review P2); a
|
|
264
|
+
* group with any unnumbered row (recorded before v1.32.0) keeps file
|
|
265
|
+
* order.
|
|
240
266
|
* A call after the last occurrence is a miss: under `onMiss: 'throw'`
|
|
241
267
|
* it raises a VcrMissError whose `recordedOccurrences` says the hash
|
|
242
268
|
* WAS recorded but is exhausted, and under `'passthrough'` it
|
|
@@ -257,7 +283,13 @@ declare function readCassette(path: string): VcrCassette;
|
|
|
257
283
|
* both declarations; a conflict refuses with a typed ConfigError
|
|
258
284
|
* before anything is served. A cassette recorded before v1.31.0
|
|
259
285
|
* stores no usageSemantics, so its replays stamp nothing (documented
|
|
260
|
-
* historical laxity).
|
|
286
|
+
* historical laxity). Under `onMiss: 'passthrough'` the recorded
|
|
287
|
+
* declarations must also match the live adapter's, absent versus
|
|
288
|
+
* present included, because a live served miss is journaled under
|
|
289
|
+
* the wrapper's declarations; a mismatch refuses at construction
|
|
290
|
+
* (v1.31.0 review P2). An adapter with no recorded rows keeps the
|
|
291
|
+
* live adapter's own declarations, so the wrapper stays a metadata
|
|
292
|
+
* preserving drop in.
|
|
261
293
|
*/
|
|
262
294
|
declare function replay(options: {
|
|
263
295
|
cassette: string;
|
package/dist/index.js
CHANGED
|
@@ -336,63 +336,78 @@ function headerLine() {
|
|
|
336
336
|
* (a requested abort or a truncated read), throws, or violates the
|
|
337
337
|
* adapter contract (a second terminal, data after the terminal)
|
|
338
338
|
* appends nothing, so a cassette row is always the record of one
|
|
339
|
-
* completed exchange (v1.28.0 review P2).
|
|
340
|
-
*
|
|
339
|
+
* completed exchange (v1.28.0 review P2). Every call also claims a
|
|
340
|
+
* per `(adapterId, requestHash)` occurrence number synchronously in
|
|
341
|
+
* the `stream()` call itself and persists it on the completed row,
|
|
342
|
+
* so replay can restore the caller to response association even when
|
|
343
|
+
* concurrent identical calls completed out of order (v1.31.0 review
|
|
344
|
+
* P2). The wrapped adapters are drop-in: same ids, providers, caps,
|
|
345
|
+
* and event streams.
|
|
341
346
|
*/
|
|
342
347
|
function record(options) {
|
|
343
348
|
const redact = options.redact ? (value) => defaultRedact(options.redact ? options.redact(value) : value) : defaultRedact;
|
|
344
349
|
if (!existsSync(options.cassette)) writeFileSync(options.cassette, `${headerLine()}\n`, "utf8");
|
|
345
|
-
return options.adapters.map((adapter) =>
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
350
|
+
return options.adapters.map((adapter) => {
|
|
351
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
352
|
+
return {
|
|
353
|
+
...adapter,
|
|
354
|
+
id: adapter.id,
|
|
355
|
+
...adapter.provider === void 0 ? {} : { provider: adapter.provider },
|
|
356
|
+
caps: (model) => adapter.caps(model),
|
|
357
|
+
stream(req, signal) {
|
|
358
|
+
const hash = requestHash(req);
|
|
359
|
+
const occurrence = occurrences.get(hash) ?? 0;
|
|
360
|
+
occurrences.set(hash, occurrence + 1);
|
|
361
|
+
return (async function* () {
|
|
362
|
+
const events = [];
|
|
363
|
+
let thrown = false;
|
|
364
|
+
let terminals = 0;
|
|
365
|
+
let postTerminal = false;
|
|
366
|
+
try {
|
|
367
|
+
for await (const event of adapter.stream(req, signal)) {
|
|
368
|
+
if (terminals > 0) postTerminal = true;
|
|
369
|
+
if (isTerminalEvent(event)) terminals += 1;
|
|
370
|
+
events.push(event);
|
|
371
|
+
yield event;
|
|
372
|
+
}
|
|
373
|
+
} catch (error) {
|
|
374
|
+
thrown = true;
|
|
375
|
+
throw error;
|
|
376
|
+
} finally {
|
|
377
|
+
if (!thrown && terminals === 1 && !postTerminal) {
|
|
378
|
+
const row = {
|
|
379
|
+
adapterId: adapter.id,
|
|
380
|
+
...adapter.provider === void 0 ? {} : { provider: adapter.provider },
|
|
381
|
+
...adapter.usageSemantics === void 0 ? {} : { usageSemantics: adapter.usageSemantics },
|
|
382
|
+
occurrence,
|
|
383
|
+
requestHash: hash,
|
|
384
|
+
request: walkStrings(JSON.parse(JSON.stringify(req)), redact),
|
|
385
|
+
events: walkStrings(JSON.parse(JSON.stringify(events)), redact),
|
|
386
|
+
caps: adapter.caps(req.model),
|
|
387
|
+
model: req.model
|
|
388
|
+
};
|
|
389
|
+
appendFileSync(options.cassette, `${JSON.stringify(row)}\n`, "utf8");
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
})();
|
|
379
393
|
}
|
|
380
|
-
}
|
|
381
|
-
})
|
|
394
|
+
};
|
|
395
|
+
});
|
|
382
396
|
}
|
|
383
397
|
/**
|
|
384
398
|
* Typed hermetic-miss error; onMiss: 'throw' raises it on any request
|
|
385
399
|
* without a servable row. `recordedOccurrences` above zero means the
|
|
386
400
|
* 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
|
|
401
|
+
* serves each recorded exchange once, in recorded order); absent or
|
|
402
|
+
* zero means the request was never recorded at all (v1.29.0 review
|
|
403
|
+
* P2).
|
|
389
404
|
*/
|
|
390
405
|
var VcrMissError = class extends Error {
|
|
391
406
|
requestHash;
|
|
392
407
|
/** Rows recorded for this hash; absent or 0 = never recorded. */
|
|
393
408
|
recordedOccurrences;
|
|
394
409
|
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
|
|
410
|
+
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
411
|
this.name = "VcrMissError";
|
|
397
412
|
this.requestHash = hash;
|
|
398
413
|
if (recordedOccurrences !== void 0) this.recordedOccurrences = recordedOccurrences;
|
|
@@ -493,7 +508,9 @@ function eventShapeIssue(event, index) {
|
|
|
493
508
|
case "tool-call-delta":
|
|
494
509
|
if (typeof event.id !== "string" || event.id === "") return `${at}.id must be a nonempty string`;
|
|
495
510
|
return typeof event.argsTextDelta === "string" ? void 0 : `${at}.argsTextDelta must be a string`;
|
|
496
|
-
case "tool-call-end":
|
|
511
|
+
case "tool-call-end":
|
|
512
|
+
if (typeof event.id !== "string" || event.id === "") return `${at}.id must be a nonempty string`;
|
|
513
|
+
return Object.hasOwn(event, "args") ? void 0 : `${at}.args must be present (the arguments the call ended with)`;
|
|
497
514
|
case "usage": {
|
|
498
515
|
const usage = event.usage;
|
|
499
516
|
if (!isPlainObject(usage)) return `${at}.usage must be an object`;
|
|
@@ -511,7 +528,20 @@ function eventShapeIssue(event, index) {
|
|
|
511
528
|
if (reason === "refusal") {
|
|
512
529
|
const refusal = finish.refusal;
|
|
513
530
|
if (!isPlainObject(refusal) || typeof refusal.provider !== "string") return `${at}.finish.refusal must be an object naming the provider`;
|
|
531
|
+
const stopDetails = refusal.stopDetails;
|
|
532
|
+
if (stopDetails !== void 0) {
|
|
533
|
+
if (!isPlainObject(stopDetails)) return `${at}.finish.refusal.stopDetails must be an object when present`;
|
|
534
|
+
for (const field of [
|
|
535
|
+
"type",
|
|
536
|
+
"category",
|
|
537
|
+
"explanation"
|
|
538
|
+
]) {
|
|
539
|
+
const value = stopDetails[field];
|
|
540
|
+
if (value !== void 0 && typeof value !== "string") return `${at}.finish.refusal.stopDetails.${field} must be a string when present`;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
514
543
|
}
|
|
544
|
+
if (event.providerMetadata !== void 0 && !isPlainObject(event.providerMetadata)) return `${at}.providerMetadata must be a plain object when present`;
|
|
515
545
|
const usage = event.usage;
|
|
516
546
|
if (!isPlainObject(usage)) return `${at}.usage must be an object (the full Usage of the exchange)`;
|
|
517
547
|
const violations = usageViolations(usage);
|
|
@@ -536,7 +566,8 @@ function eventShapeIssue(event, index) {
|
|
|
536
566
|
* instead of being read as v1. Every documented header field (kind,
|
|
537
567
|
* v, an integer hashVersion, a date string recordedAt) and row field
|
|
538
568
|
* (adapterId, model, requestHash, request, caps, events, an optional
|
|
539
|
-
* string provider, an optional nonempty usageSemantics
|
|
569
|
+
* string provider, an optional nonempty usageSemantics, an optional
|
|
570
|
+
* nonnegative integer occurrence) is checked
|
|
540
571
|
* here, and the nested structures are validated in depth (v1.30.0
|
|
541
572
|
* review P3): the request must be a plain object, every event must
|
|
542
573
|
* be a member of the canonical ChatEvent vocabulary with its
|
|
@@ -583,6 +614,7 @@ function readCassette(path) {
|
|
|
583
614
|
if (typeof row.model !== "string" || row.model === "") reject("model must be a nonempty string");
|
|
584
615
|
if (row.provider !== void 0 && typeof row.provider !== "string") reject("provider, when present, must be a string");
|
|
585
616
|
if (row.usageSemantics !== void 0 && (typeof row.usageSemantics !== "string" || row.usageSemantics === "")) reject("usageSemantics, when present, must be a nonempty string");
|
|
617
|
+
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
618
|
if (typeof row.request !== "object" || row.request === null || Array.isArray(row.request)) reject("request must be an object (the redacted canonical request)");
|
|
587
619
|
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
620
|
const capsIssue = capsShapeIssue(row.caps);
|
|
@@ -602,11 +634,17 @@ function readCassette(path) {
|
|
|
602
634
|
* hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
|
|
603
635
|
* matching live adapter in `adapters` (a development convenience only).
|
|
604
636
|
*
|
|
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
|
|
637
|
+
* Repeated hashes replay as ordered occurrences (v1.29.0 review P2):
|
|
638
|
+
* rows sharing a `(adapterId, requestHash)` key form an ordered
|
|
639
|
+
* occurrence list, and every `stream()` call consumes exactly one
|
|
640
|
+
* occurrence, allocated synchronously inside the call itself, so two
|
|
641
|
+
* concurrent identical requests can never be served the same
|
|
642
|
+
* recorded exchange. The list is sorted by the recorded `occurrence`
|
|
643
|
+
* numbers when every row of the group carries one, so concurrent
|
|
644
|
+
* identical calls whose live completions were appended out of order
|
|
645
|
+
* still replay to the callers that made them (v1.31.0 review P2); a
|
|
646
|
+
* group with any unnumbered row (recorded before v1.32.0) keeps file
|
|
647
|
+
* order.
|
|
610
648
|
* A call after the last occurrence is a miss: under `onMiss: 'throw'`
|
|
611
649
|
* it raises a VcrMissError whose `recordedOccurrences` says the hash
|
|
612
650
|
* WAS recorded but is exhausted, and under `'passthrough'` it
|
|
@@ -627,7 +665,13 @@ function readCassette(path) {
|
|
|
627
665
|
* both declarations; a conflict refuses with a typed ConfigError
|
|
628
666
|
* before anything is served. A cassette recorded before v1.31.0
|
|
629
667
|
* stores no usageSemantics, so its replays stamp nothing (documented
|
|
630
|
-
* historical laxity).
|
|
668
|
+
* historical laxity). Under `onMiss: 'passthrough'` the recorded
|
|
669
|
+
* declarations must also match the live adapter's, absent versus
|
|
670
|
+
* present included, because a live served miss is journaled under
|
|
671
|
+
* the wrapper's declarations; a mismatch refuses at construction
|
|
672
|
+
* (v1.31.0 review P2). An adapter with no recorded rows keeps the
|
|
673
|
+
* live adapter's own declarations, so the wrapper stays a metadata
|
|
674
|
+
* preserving drop in.
|
|
631
675
|
*/
|
|
632
676
|
function replay(options) {
|
|
633
677
|
const { header, rows } = readCassette(options.cassette);
|
|
@@ -646,6 +690,7 @@ function replay(options) {
|
|
|
646
690
|
forAdapter.set(row.requestHash, occurrences);
|
|
647
691
|
byAdapter.set(row.adapterId, forAdapter);
|
|
648
692
|
}
|
|
693
|
+
for (const forAdapter of byAdapter.values()) for (const occurrences of forAdapter.values()) if (occurrences.every((row) => row.occurrence !== void 0)) occurrences.sort((a, b) => (a.occurrence ?? 0) - (b.occurrence ?? 0));
|
|
649
694
|
const live = new Map((options.adapters ?? []).map((adapter) => [adapter.id, adapter]));
|
|
650
695
|
return [.../* @__PURE__ */ new Set([...byAdapter.keys(), ...live.keys()])].map((adapterId) => {
|
|
651
696
|
const recorded = byAdapter.get(adapterId) ?? /* @__PURE__ */ new Map();
|
|
@@ -666,11 +711,18 @@ function replay(options) {
|
|
|
666
711
|
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
712
|
}
|
|
668
713
|
const passthrough = live.get(adapterId);
|
|
714
|
+
if (someRow !== void 0 && passthrough !== void 0 && options.onMiss === "passthrough") {
|
|
715
|
+
for (const field of ["provider", "usageSemantics"]) if (someRow[field] !== passthrough[field]) {
|
|
716
|
+
const describe = (value) => value === void 0 ? "absent" : `'${value}'`;
|
|
717
|
+
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`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const declared = someRow ?? passthrough;
|
|
669
721
|
const cursors = /* @__PURE__ */ new Map();
|
|
670
722
|
return {
|
|
671
723
|
id: adapterId,
|
|
672
|
-
...
|
|
673
|
-
...
|
|
724
|
+
...declared?.provider === void 0 ? {} : { provider: declared.provider },
|
|
725
|
+
...declared?.usageSemantics === void 0 ? {} : { usageSemantics: declared.usageSemantics },
|
|
674
726
|
caps: (model) => {
|
|
675
727
|
const snapshot = capsByModel.get(model)?.caps ?? passthrough?.caps(model);
|
|
676
728
|
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.32.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.32.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^22.20.0",
|