@rulvar/testing 1.32.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 +23 -4
- package/dist/index.js +70 -14
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -150,7 +150,11 @@ 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).
|
|
154
158
|
*/
|
|
155
159
|
occurrence?: number;
|
|
156
160
|
requestHash: string;
|
|
@@ -195,8 +199,19 @@ declare function requestHash(req: ChatRequest): string;
|
|
|
195
199
|
* the `stream()` call itself and persists it on the completed row,
|
|
196
200
|
* so replay can restore the caller to response association even when
|
|
197
201
|
* concurrent identical calls completed out of order (v1.31.0 review
|
|
198
|
-
* P2).
|
|
199
|
-
* and
|
|
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.
|
|
200
215
|
*/
|
|
201
216
|
declare function record(options: {
|
|
202
217
|
adapters: ProviderAdapter[];
|
|
@@ -262,7 +277,11 @@ declare function readCassette(path: string): VcrCassette;
|
|
|
262
277
|
* identical calls whose live completions were appended out of order
|
|
263
278
|
* still replay to the callers that made them (v1.31.0 review P2); a
|
|
264
279
|
* group with any unnumbered row (recorded before v1.32.0) keeps file
|
|
265
|
-
* order.
|
|
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).
|
|
266
285
|
* A call after the last occurrence is a miss: under `onMiss: 'throw'`
|
|
267
286
|
* it raises a VcrMissError whose `recordedOccurrences` says the hash
|
|
268
287
|
* 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,37 @@ 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 wrapped adapters are drop-in:
|
|
393
|
+
* same ids, providers, caps, and event streams.
|
|
346
394
|
*/
|
|
347
395
|
function record(options) {
|
|
348
396
|
const redact = options.redact ? (value) => defaultRedact(options.redact ? options.redact(value) : value) : defaultRedact;
|
|
349
|
-
|
|
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);
|
|
406
|
+
}
|
|
407
|
+
seeds.set(adapterId, forSeeds);
|
|
408
|
+
}
|
|
409
|
+
} else writeFileSync(options.cassette, `${headerLine()}\n`, "utf8");
|
|
350
410
|
return options.adapters.map((adapter) => {
|
|
351
|
-
const occurrences =
|
|
411
|
+
const occurrences = new Map(seeds.get(adapter.id) ?? []);
|
|
352
412
|
return {
|
|
353
413
|
...adapter,
|
|
354
414
|
id: adapter.id,
|
|
@@ -644,7 +704,11 @@ function readCassette(path) {
|
|
|
644
704
|
* identical calls whose live completions were appended out of order
|
|
645
705
|
* still replay to the callers that made them (v1.31.0 review P2); a
|
|
646
706
|
* group with any unnumbered row (recorded before v1.32.0) keeps file
|
|
647
|
-
* order.
|
|
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).
|
|
648
712
|
* A call after the last occurrence is a miss: under `onMiss: 'throw'`
|
|
649
713
|
* it raises a VcrMissError whose `recordedOccurrences` says the hash
|
|
650
714
|
* WAS recorded but is exhausted, and under `'passthrough'` it
|
|
@@ -682,15 +746,7 @@ function replay(options) {
|
|
|
682
746
|
const last = row.events[row.events.length - 1];
|
|
683
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`);
|
|
684
748
|
});
|
|
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));
|
|
749
|
+
const byAdapter = groupRows(rows, options.cassette);
|
|
694
750
|
const live = new Map((options.adapters ?? []).map((adapter) => [adapter.id, adapter]));
|
|
695
751
|
return [.../* @__PURE__ */ new Set([...byAdapter.keys(), ...live.keys()])].map((adapterId) => {
|
|
696
752
|
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.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",
|