@rulvar/testing 1.32.0 → 1.34.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 +33 -4
- package/dist/index.js +80 -14
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -150,7 +150,14 @@ interface VcrRow {
|
|
|
150
150
|
* it when every row of the group carries one; absent in cassettes
|
|
151
151
|
* recorded before v1.32.0, whose same hash rows keep file order.
|
|
152
152
|
* An aborted or failed call claims a number but appends no row, so
|
|
153
|
-
* gaps in the numbering are valid.
|
|
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). The numbering ends at
|
|
158
|
+
* `Number.MAX_SAFE_INTEGER`: a session refuses with a typed
|
|
159
|
+
* ConfigError to claim a number past it, before dispatching the
|
|
160
|
+
* provider and before touching the file (v1.33.0 review P3).
|
|
154
161
|
*/
|
|
155
162
|
occurrence?: number;
|
|
156
163
|
requestHash: string;
|
|
@@ -195,8 +202,26 @@ declare function requestHash(req: ChatRequest): string;
|
|
|
195
202
|
* the `stream()` call itself and persists it on the completed row,
|
|
196
203
|
* so replay can restore the caller to response association even when
|
|
197
204
|
* concurrent identical calls completed out of order (v1.31.0 review
|
|
198
|
-
* P2).
|
|
199
|
-
* and
|
|
205
|
+
* P2). A later `record()` call on the same cassette file is an
|
|
206
|
+
* appending session: the existing file is read and validated first
|
|
207
|
+
* (a target that was never a cassette, a header whose hashVersion is
|
|
208
|
+
* not the one this build records under, and a file whose occurrence
|
|
209
|
+
* numbering is already ambiguous all refuse with a typed
|
|
210
|
+
* ConfigError), and every hash counter is seeded past the numbers
|
|
211
|
+
* already on disk, so the numbering continues where the file left
|
|
212
|
+
* off instead of restarting at zero (v1.32.0 review P2). One
|
|
213
|
+
* recorder session may be active on a cassette at a time: two
|
|
214
|
+
* concurrently constructed recorders seed identically and claim
|
|
215
|
+
* colliding numbers, which replay refuses as ambiguous instead of
|
|
216
|
+
* silently serving either order. The numbering ends at
|
|
217
|
+
* `Number.MAX_SAFE_INTEGER`: a group that already numbers it refuses
|
|
218
|
+
* the appending session at construction, and a session whose counter
|
|
219
|
+
* would pass it refuses that call before dispatching the provider,
|
|
220
|
+
* both with a typed ConfigError and without touching the file,
|
|
221
|
+
* because the next float increment would stall at 2 ** 53 and
|
|
222
|
+
* silently duplicate one unsafe number on every following row
|
|
223
|
+
* (v1.33.0 review P3). The wrapped adapters are drop-in:
|
|
224
|
+
* same ids, providers, caps, and event streams.
|
|
200
225
|
*/
|
|
201
226
|
declare function record(options: {
|
|
202
227
|
adapters: ProviderAdapter[];
|
|
@@ -262,7 +287,11 @@ declare function readCassette(path: string): VcrCassette;
|
|
|
262
287
|
* identical calls whose live completions were appended out of order
|
|
263
288
|
* still replay to the callers that made them (v1.31.0 review P2); a
|
|
264
289
|
* group with any unnumbered row (recorded before v1.32.0) keeps file
|
|
265
|
-
* order.
|
|
290
|
+
* order. A duplicate occurrence inside a fully numbered group
|
|
291
|
+
* refuses the whole cassette with a typed ConfigError naming the
|
|
292
|
+
* adapter and hash: it means two recorder sessions wrote the file
|
|
293
|
+
* concurrently, and serving either order would hand a caller the
|
|
294
|
+
* wrong exchange (v1.32.0 review P2).
|
|
266
295
|
* A call after the last occurrence is a miss: under `onMiss: 'throw'`
|
|
267
296
|
* it raises a VcrMissError whose `recordedOccurrences` says the hash
|
|
268
297
|
* WAS recorded but is exhausted, and under `'passthrough'` it
|
package/dist/index.js
CHANGED
|
@@ -330,6 +330,43 @@ 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
|
|
@@ -341,14 +378,46 @@ function headerLine() {
|
|
|
341
378
|
* the `stream()` call itself and persists it on the completed row,
|
|
342
379
|
* so replay can restore the caller to response association even when
|
|
343
380
|
* concurrent identical calls completed out of order (v1.31.0 review
|
|
344
|
-
* P2).
|
|
345
|
-
* and
|
|
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 numbering ends at
|
|
393
|
+
* `Number.MAX_SAFE_INTEGER`: a group that already numbers it refuses
|
|
394
|
+
* the appending session at construction, and a session whose counter
|
|
395
|
+
* would pass it refuses that call before dispatching the provider,
|
|
396
|
+
* both with a typed ConfigError and without touching the file,
|
|
397
|
+
* because the next float increment would stall at 2 ** 53 and
|
|
398
|
+
* silently duplicate one unsafe number on every following row
|
|
399
|
+
* (v1.33.0 review P3). The wrapped adapters are drop-in:
|
|
400
|
+
* same ids, providers, caps, and event streams.
|
|
346
401
|
*/
|
|
347
402
|
function record(options) {
|
|
348
403
|
const redact = options.redact ? (value) => defaultRedact(options.redact ? options.redact(value) : value) : defaultRedact;
|
|
349
|
-
|
|
404
|
+
const seeds = /* @__PURE__ */ new Map();
|
|
405
|
+
if (existsSync(options.cassette)) {
|
|
406
|
+
const existing = readCassette(options.cassette);
|
|
407
|
+
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`);
|
|
408
|
+
for (const [adapterId, forAdapter] of groupRows(existing.rows, options.cassette)) {
|
|
409
|
+
const forSeeds = /* @__PURE__ */ new Map();
|
|
410
|
+
for (const [hash, rows] of forAdapter) {
|
|
411
|
+
let highest = -1;
|
|
412
|
+
for (const row of rows) if (row.occurrence !== void 0 && row.occurrence > highest) highest = row.occurrence;
|
|
413
|
+
if (highest >= Number.MAX_SAFE_INTEGER) throw new ConfigError(`${options.cassette} already numbers occurrence ${String(Number.MAX_SAFE_INTEGER)} for adapter '${adapterId}' hash ${hash.slice(0, 12)}; the numbering has reached the safe integer ceiling, so no further exchange for this request can be appended; record a fresh cassette instead`);
|
|
414
|
+
forSeeds.set(hash, highest + 1);
|
|
415
|
+
}
|
|
416
|
+
seeds.set(adapterId, forSeeds);
|
|
417
|
+
}
|
|
418
|
+
} else writeFileSync(options.cassette, `${headerLine()}\n`, "utf8");
|
|
350
419
|
return options.adapters.map((adapter) => {
|
|
351
|
-
const occurrences =
|
|
420
|
+
const occurrences = new Map(seeds.get(adapter.id) ?? []);
|
|
352
421
|
return {
|
|
353
422
|
...adapter,
|
|
354
423
|
id: adapter.id,
|
|
@@ -357,6 +426,7 @@ function record(options) {
|
|
|
357
426
|
stream(req, signal) {
|
|
358
427
|
const hash = requestHash(req);
|
|
359
428
|
const occurrence = occurrences.get(hash) ?? 0;
|
|
429
|
+
if (!Number.isSafeInteger(occurrence)) throw new ConfigError(`${options.cassette} has no safe occurrence number left for adapter '${adapter.id}' hash ${hash.slice(0, 12)}; an earlier call in this session claimed ${String(Number.MAX_SAFE_INTEGER)}, so this exchange cannot be numbered; record a fresh cassette instead`);
|
|
360
430
|
occurrences.set(hash, occurrence + 1);
|
|
361
431
|
return (async function* () {
|
|
362
432
|
const events = [];
|
|
@@ -644,7 +714,11 @@ function readCassette(path) {
|
|
|
644
714
|
* identical calls whose live completions were appended out of order
|
|
645
715
|
* still replay to the callers that made them (v1.31.0 review P2); a
|
|
646
716
|
* group with any unnumbered row (recorded before v1.32.0) keeps file
|
|
647
|
-
* order.
|
|
717
|
+
* order. A duplicate occurrence inside a fully numbered group
|
|
718
|
+
* refuses the whole cassette with a typed ConfigError naming the
|
|
719
|
+
* adapter and hash: it means two recorder sessions wrote the file
|
|
720
|
+
* concurrently, and serving either order would hand a caller the
|
|
721
|
+
* wrong exchange (v1.32.0 review P2).
|
|
648
722
|
* A call after the last occurrence is a miss: under `onMiss: 'throw'`
|
|
649
723
|
* it raises a VcrMissError whose `recordedOccurrences` says the hash
|
|
650
724
|
* WAS recorded but is exhausted, and under `'passthrough'` it
|
|
@@ -682,15 +756,7 @@ function replay(options) {
|
|
|
682
756
|
const last = row.events[row.events.length - 1];
|
|
683
757
|
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`);
|
|
684
758
|
});
|
|
685
|
-
const byAdapter =
|
|
686
|
-
for (const row of rows) {
|
|
687
|
-
const forAdapter = byAdapter.get(row.adapterId) ?? /* @__PURE__ */ new Map();
|
|
688
|
-
const occurrences = forAdapter.get(row.requestHash) ?? [];
|
|
689
|
-
occurrences.push(row);
|
|
690
|
-
forAdapter.set(row.requestHash, occurrences);
|
|
691
|
-
byAdapter.set(row.adapterId, forAdapter);
|
|
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));
|
|
759
|
+
const byAdapter = groupRows(rows, options.cassette);
|
|
694
760
|
const live = new Map((options.adapters ?? []).map((adapter) => [adapter.id, adapter]));
|
|
695
761
|
return [.../* @__PURE__ */ new Set([...byAdapter.keys(), ...live.keys()])].map((adapterId) => {
|
|
696
762
|
const recorded = byAdapter.get(adapterId) ?? /* @__PURE__ */ new Map();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/testing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.34.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.34.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^22.20.0",
|