@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 +39 -7
- package/dist/index.js +105 -27
- package/package.json +2 -2
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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
191
|
-
* gates request identity and never
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
382
|
-
|
|
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 (
|
|
391
|
-
* gates request identity and never
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
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
|
|
416
|
-
|
|
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 (
|
|
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.
|
|
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
|
|
440
|
-
const someRow = [
|
|
496
|
+
const recordedRows = [...recorded.values()].flat();
|
|
497
|
+
const someRow = recordedRows[0];
|
|
441
498
|
const capsByModel = /* @__PURE__ */ new Map();
|
|
442
|
-
for (const row of
|
|
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
|
-
|
|
518
|
+
stream(req, signal) {
|
|
452
519
|
const hash = requestHash(req);
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
"@rulvar/core": "1.30.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^22.20.0",
|