@rulvar/testing 1.29.0 → 1.31.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 +65 -5
- package/dist/index.js +265 -26
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -130,6 +130,16 @@ declare function runLiveSmoke(adapter: Pick<ProviderAdapter, "stream">, req: Cha
|
|
|
130
130
|
interface VcrRow {
|
|
131
131
|
adapterId: string;
|
|
132
132
|
provider?: string;
|
|
133
|
+
/**
|
|
134
|
+
* The recording adapter's declared usageSemantics snapshot (v1.30.0
|
|
135
|
+
* review P2): replay restores it on the rebuilt adapter, so the
|
|
136
|
+
* fresh journal of a replayed run carries the same provenance stamp
|
|
137
|
+
* the recorded run got. Absent when the recording adapter declared
|
|
138
|
+
* none, and in every cassette recorded before v1.31.0, whose
|
|
139
|
+
* replays therefore stamp nothing (documented historical laxity; an
|
|
140
|
+
* unstamped entry reads as recorded before the stamp existed).
|
|
141
|
+
*/
|
|
142
|
+
usageSemantics?: string;
|
|
133
143
|
requestHash: string;
|
|
134
144
|
/** Redacted canonical request, for humans and drift review. */
|
|
135
145
|
request: unknown;
|
|
@@ -175,10 +185,18 @@ declare function record(options: {
|
|
|
175
185
|
cassette: string;
|
|
176
186
|
redact?: RedactFn;
|
|
177
187
|
}): ProviderAdapter[];
|
|
178
|
-
/**
|
|
188
|
+
/**
|
|
189
|
+
* Typed hermetic-miss error; onMiss: 'throw' raises it on any request
|
|
190
|
+
* without a servable row. `recordedOccurrences` above zero means the
|
|
191
|
+
* hash WAS recorded but every occurrence is already consumed (replay
|
|
192
|
+
* serves each recorded exchange once, in file order); absent or zero
|
|
193
|
+
* means the request was never recorded at all (v1.29.0 review P2).
|
|
194
|
+
*/
|
|
179
195
|
declare class VcrMissError extends Error {
|
|
180
196
|
readonly requestHash: string;
|
|
181
|
-
|
|
197
|
+
/** Rows recorded for this hash; absent or 0 = never recorded. */
|
|
198
|
+
readonly recordedOccurrences?: number;
|
|
199
|
+
constructor(adapterId: string, hash: string, recordedOccurrences?: number);
|
|
182
200
|
}
|
|
183
201
|
interface VcrCassette {
|
|
184
202
|
header: VcrHeader;
|
|
@@ -187,9 +205,24 @@ interface VcrCassette {
|
|
|
187
205
|
/**
|
|
188
206
|
* Parses a cassette file (one header line plus one JSON row per line).
|
|
189
207
|
* The header must declare cassette format `v: 1`: the format version
|
|
190
|
-
* gates parsing itself, while hashVersion (
|
|
191
|
-
* gates request identity and never
|
|
192
|
-
*
|
|
208
|
+
* gates parsing itself, while hashVersion (whose support window is
|
|
209
|
+
* checked by replay) only gates request identity and never
|
|
210
|
+
* substitutes for it, so a future incompatible format refuses loudly
|
|
211
|
+
* instead of being read as v1. Every documented header field (kind,
|
|
212
|
+
* v, an integer hashVersion, a date string recordedAt) and row field
|
|
213
|
+
* (adapterId, model, requestHash, request, caps, events, an optional
|
|
214
|
+
* string provider, an optional nonempty usageSemantics) is checked
|
|
215
|
+
* here, and the nested structures are validated in depth (v1.30.0
|
|
216
|
+
* review P3): the request must be a plain object, every event must
|
|
217
|
+
* be a member of the canonical ChatEvent vocabulary with its
|
|
218
|
+
* required payload and Usage numeric invariants, and caps must carry
|
|
219
|
+
* every ModelCaps field (with the optional pricing table checked
|
|
220
|
+
* when present). Unknown extra FIELDS are tolerated for forward
|
|
221
|
+
* compatibility. Event stream SEMANTICS (one trailing terminal per
|
|
222
|
+
* row) and adapter consistency across rows (provider,
|
|
223
|
+
* usageSemantics, caps agreement) are deliberately not checked at
|
|
224
|
+
* read time; `replay` enforces them before serving anything (v1.29.0
|
|
225
|
+
* review P3), so reading never blocks inspecting a well formed file.
|
|
193
226
|
* Parse and shape failures throw a typed ConfigError naming the
|
|
194
227
|
* cassette path and line (v1.28.0 review P3).
|
|
195
228
|
*/
|
|
@@ -198,6 +231,33 @@ declare function readCassette(path: string): VcrCassette;
|
|
|
198
231
|
* Builds replay adapters from a cassette. `onMiss: 'throw'` is the
|
|
199
232
|
* hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
|
|
200
233
|
* matching live adapter in `adapters` (a development convenience only).
|
|
234
|
+
*
|
|
235
|
+
* Repeated hashes replay in file order (v1.29.0 review P2): rows
|
|
236
|
+
* sharing a `(adapterId, requestHash)` key form an ordered occurrence
|
|
237
|
+
* list, and every `stream()` call consumes exactly one occurrence,
|
|
238
|
+
* allocated synchronously inside the call itself, so two concurrent
|
|
239
|
+
* identical requests can never be served the same recorded exchange.
|
|
240
|
+
* A call after the last occurrence is a miss: under `onMiss: 'throw'`
|
|
241
|
+
* it raises a VcrMissError whose `recordedOccurrences` says the hash
|
|
242
|
+
* WAS recorded but is exhausted, and under `'passthrough'` it
|
|
243
|
+
* forwards to the live adapter exactly like a never-recorded request.
|
|
244
|
+
*
|
|
245
|
+
* Before serving anything, replay also enforces what `record` has
|
|
246
|
+
* guaranteed since v1.29.0: every row's event stream ends with
|
|
247
|
+
* exactly one terminal event (finish or error), and all caps
|
|
248
|
+
* snapshots for one `(adapterId, model)` agree, since the replay
|
|
249
|
+
* adapter can only report one caps truth per model. Violations throw
|
|
250
|
+
* a typed ConfigError naming the cassette and row.
|
|
251
|
+
*
|
|
252
|
+
* The rebuilt adapter restores the recorded provider and
|
|
253
|
+
* usageSemantics declarations (v1.30.0 review P2), so the fresh
|
|
254
|
+
* journal of a replayed run carries the same provenance stamp the
|
|
255
|
+
* recorded run got instead of silently reading like an entry from
|
|
256
|
+
* before the stamp existed. All rows of one adapter must agree on
|
|
257
|
+
* both declarations; a conflict refuses with a typed ConfigError
|
|
258
|
+
* before anything is served. A cassette recorded before v1.31.0
|
|
259
|
+
* stores no usageSemantics, so its replays stamp nothing (documented
|
|
260
|
+
* historical laxity).
|
|
201
261
|
*/
|
|
202
262
|
declare function replay(options: {
|
|
203
263
|
cassette: string;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as fakeWireError, i as fakeToolCalls, n as FAKE_MODEL_REF, r as FakeAdapter, t as FAKE_MODEL } from "./fake-adapter-3T_w-IOY.js";
|
|
2
|
-
import { CURRENT_HASH_VERSION, ConfigError, InMemoryStore, JournalMissError, createEngine, hashWorkflowBody } from "@rulvar/core";
|
|
2
|
+
import { CURRENT_HASH_VERSION, ConfigError, InMemoryStore, JournalMissError, createEngine, hashWorkflowBody, usageViolations } from "@rulvar/core";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
//#region src/test-engine.ts
|
|
@@ -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
|
}
|
|
@@ -363,6 +367,7 @@ function record(options) {
|
|
|
363
367
|
const row = {
|
|
364
368
|
adapterId: adapter.id,
|
|
365
369
|
...adapter.provider === void 0 ? {} : { provider: adapter.provider },
|
|
370
|
+
...adapter.usageSemantics === void 0 ? {} : { usageSemantics: adapter.usageSemantics },
|
|
366
371
|
requestHash: requestHash(req),
|
|
367
372
|
request: walkStrings(JSON.parse(JSON.stringify(req)), redact),
|
|
368
373
|
events: walkStrings(JSON.parse(JSON.stringify(events)), redact),
|
|
@@ -375,21 +380,174 @@ function record(options) {
|
|
|
375
380
|
}
|
|
376
381
|
}));
|
|
377
382
|
}
|
|
378
|
-
/**
|
|
383
|
+
/**
|
|
384
|
+
* Typed hermetic-miss error; onMiss: 'throw' raises it on any request
|
|
385
|
+
* without a servable row. `recordedOccurrences` above zero means the
|
|
386
|
+
* hash WAS recorded but every occurrence is already consumed (replay
|
|
387
|
+
* serves each recorded exchange once, in file order); absent or zero
|
|
388
|
+
* means the request was never recorded at all (v1.29.0 review P2).
|
|
389
|
+
*/
|
|
379
390
|
var VcrMissError = class extends Error {
|
|
380
391
|
requestHash;
|
|
381
|
-
|
|
382
|
-
|
|
392
|
+
/** Rows recorded for this hash; absent or 0 = never recorded. */
|
|
393
|
+
recordedOccurrences;
|
|
394
|
+
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 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
396
|
this.name = "VcrMissError";
|
|
384
397
|
this.requestHash = hash;
|
|
398
|
+
if (recordedOccurrences !== void 0) this.recordedOccurrences = recordedOccurrences;
|
|
385
399
|
}
|
|
386
400
|
};
|
|
401
|
+
function isPlainObject(value) {
|
|
402
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
403
|
+
}
|
|
404
|
+
const EFFORTS = [
|
|
405
|
+
"low",
|
|
406
|
+
"medium",
|
|
407
|
+
"high",
|
|
408
|
+
"xhigh",
|
|
409
|
+
"max"
|
|
410
|
+
];
|
|
411
|
+
const FINISH_REASONS = [
|
|
412
|
+
"stop",
|
|
413
|
+
"tool-calls",
|
|
414
|
+
"max-tokens",
|
|
415
|
+
"context-window-exceeded",
|
|
416
|
+
"refusal"
|
|
417
|
+
];
|
|
418
|
+
const USAGE_COUNT_FIELDS = [
|
|
419
|
+
"inputTokens",
|
|
420
|
+
"outputTokens",
|
|
421
|
+
"cacheReadTokens",
|
|
422
|
+
"cacheWriteTokens",
|
|
423
|
+
"reasoningTokens"
|
|
424
|
+
];
|
|
425
|
+
/**
|
|
426
|
+
* First shape violation of a recorded caps snapshot, or undefined. The
|
|
427
|
+
* checks mirror what ModelCaps declares (v1.30.0 review P3): before
|
|
428
|
+
* this shipped an empty object passed as a snapshot and the failure
|
|
429
|
+
* surfaced later as an unrelated router or pricing defect.
|
|
430
|
+
*/
|
|
431
|
+
function capsShapeIssue(caps) {
|
|
432
|
+
if (caps.structuredOutput !== "native" && caps.structuredOutput !== "forced-tool" && caps.structuredOutput !== "prompt") return "caps.structuredOutput must be 'native', 'forced-tool', or 'prompt'";
|
|
433
|
+
for (const field of ["supportsTemperature", "supportsParallelTools"]) if (typeof caps[field] !== "boolean") return `caps.${field} must be a boolean`;
|
|
434
|
+
const efforts = caps.reasoningEfforts;
|
|
435
|
+
if (!Array.isArray(efforts) || efforts.some((entry) => typeof entry !== "string" || !EFFORTS.includes(entry))) return "caps.reasoningEfforts must be an array of canonical efforts";
|
|
436
|
+
for (const field of ["contextWindow", "maxOutputTokens"]) {
|
|
437
|
+
const value = caps[field];
|
|
438
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) return `caps.${field} must be a positive safe integer`;
|
|
439
|
+
}
|
|
440
|
+
if (caps.pricing !== void 0) {
|
|
441
|
+
const pricing = caps.pricing;
|
|
442
|
+
if (!isPlainObject(pricing)) return "caps.pricing must be an object when present";
|
|
443
|
+
for (const field of ["inputUsdPerMTok", "outputUsdPerMTok"]) {
|
|
444
|
+
const value = pricing[field];
|
|
445
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return `caps.pricing.${field} must be a nonnegative finite number`;
|
|
446
|
+
}
|
|
447
|
+
for (const field of [
|
|
448
|
+
"cacheReadUsdPerMTok",
|
|
449
|
+
"cacheWriteUsdPerMTok",
|
|
450
|
+
"cacheWrite1hUsdPerMTok"
|
|
451
|
+
]) {
|
|
452
|
+
const value = pricing[field];
|
|
453
|
+
if (value !== void 0 && (typeof value !== "number" || !Number.isFinite(value) || value < 0)) return `caps.pricing.${field} must be a nonnegative finite number when present`;
|
|
454
|
+
}
|
|
455
|
+
const tiers = pricing.tiers;
|
|
456
|
+
if (tiers !== void 0) {
|
|
457
|
+
if (!Array.isArray(tiers)) return "caps.pricing.tiers must be an array when present";
|
|
458
|
+
for (const [index, tier] of tiers.entries()) {
|
|
459
|
+
const path = `caps.pricing.tiers[${String(index)}]`;
|
|
460
|
+
if (!isPlainObject(tier)) return `${path} must be an object`;
|
|
461
|
+
const above = tier.aboveInputTokens;
|
|
462
|
+
if (typeof above !== "number" || !Number.isSafeInteger(above) || above < 0) return `${path}.aboveInputTokens must be a nonnegative safe integer`;
|
|
463
|
+
for (const field of ["inputMultiplier", "outputMultiplier"]) {
|
|
464
|
+
const value = tier[field];
|
|
465
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return `${path}.${field} must be a positive finite number`;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* First shape violation of one recorded event, or undefined. Every
|
|
473
|
+
* element must be a member of the canonical ChatEvent vocabulary with
|
|
474
|
+
* its required payload (v1.30.0 review P3): before this shipped a
|
|
475
|
+
* null element crashed replay with a raw TypeError and a bare
|
|
476
|
+
* `{ type: 'finish' }` reached the engine, which then died on the
|
|
477
|
+
* missing usage instead of refusing the cassette at its boundary.
|
|
478
|
+
* Unknown extra FIELDS on a known event stay tolerated; an unknown
|
|
479
|
+
* event TYPE is refused, because replay would feed it to an engine
|
|
480
|
+
* whose vocabulary provably does not include it (the cassette format
|
|
481
|
+
* version, not leniency here, is the growth path).
|
|
482
|
+
*/
|
|
483
|
+
function eventShapeIssue(event, index) {
|
|
484
|
+
const at = `events[${String(index)}]`;
|
|
485
|
+
if (!isPlainObject(event)) return `${at} must be an object (a canonical ChatEvent)`;
|
|
486
|
+
const type = event.type;
|
|
487
|
+
switch (type) {
|
|
488
|
+
case "text-delta":
|
|
489
|
+
case "reasoning-delta": return typeof event.text === "string" ? void 0 : `${at}.text must be a string`;
|
|
490
|
+
case "tool-call-start":
|
|
491
|
+
if (typeof event.id !== "string" || event.id === "") return `${at}.id must be a nonempty string`;
|
|
492
|
+
return typeof event.name === "string" && event.name !== "" ? void 0 : `${at}.name must be a nonempty string`;
|
|
493
|
+
case "tool-call-delta":
|
|
494
|
+
if (typeof event.id !== "string" || event.id === "") return `${at}.id must be a nonempty string`;
|
|
495
|
+
return typeof event.argsTextDelta === "string" ? void 0 : `${at}.argsTextDelta must be a string`;
|
|
496
|
+
case "tool-call-end": return typeof event.id === "string" && event.id !== "" ? void 0 : `${at}.id must be a nonempty string`;
|
|
497
|
+
case "usage": {
|
|
498
|
+
const usage = event.usage;
|
|
499
|
+
if (!isPlainObject(usage)) return `${at}.usage must be an object`;
|
|
500
|
+
for (const field of USAGE_COUNT_FIELDS) {
|
|
501
|
+
const value = usage[field];
|
|
502
|
+
if (value !== void 0 && (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) return `${at}.usage.${field} must be a nonnegative safe integer when present`;
|
|
503
|
+
}
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
case "finish": {
|
|
507
|
+
const finish = event.finish;
|
|
508
|
+
if (!isPlainObject(finish)) return `${at}.finish must be an object (a typed FinishInfo)`;
|
|
509
|
+
const reason = finish.reason;
|
|
510
|
+
if (typeof reason !== "string" || !FINISH_REASONS.includes(reason)) return `${at}.finish.reason must be a canonical finish reason`;
|
|
511
|
+
if (reason === "refusal") {
|
|
512
|
+
const refusal = finish.refusal;
|
|
513
|
+
if (!isPlainObject(refusal) || typeof refusal.provider !== "string") return `${at}.finish.refusal must be an object naming the provider`;
|
|
514
|
+
}
|
|
515
|
+
const usage = event.usage;
|
|
516
|
+
if (!isPlainObject(usage)) return `${at}.usage must be an object (the full Usage of the exchange)`;
|
|
517
|
+
const violations = usageViolations(usage);
|
|
518
|
+
return violations.length === 0 ? void 0 : `${at}.usage violates the Usage invariant: ${violations.join("; ")}`;
|
|
519
|
+
}
|
|
520
|
+
case "error": {
|
|
521
|
+
const error = event.error;
|
|
522
|
+
if (!isPlainObject(error)) return `${at}.error must be an object (a WireError)`;
|
|
523
|
+
if (typeof error.code !== "string" || error.code === "") return `${at}.error.code must be a nonempty string`;
|
|
524
|
+
if (typeof error.message !== "string") return `${at}.error.message must be a string`;
|
|
525
|
+
return typeof error.retryable === "boolean" ? void 0 : `${at}.error.retryable must be a boolean`;
|
|
526
|
+
}
|
|
527
|
+
default: return `${at}.type must be a canonical ChatEvent type; got ${typeof type === "string" ? `'${type}'` : String(type)}`;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
387
530
|
/**
|
|
388
531
|
* Parses a cassette file (one header line plus one JSON row per line).
|
|
389
532
|
* The header must declare cassette format `v: 1`: the format version
|
|
390
|
-
* gates parsing itself, while hashVersion (
|
|
391
|
-
* gates request identity and never
|
|
392
|
-
*
|
|
533
|
+
* gates parsing itself, while hashVersion (whose support window is
|
|
534
|
+
* checked by replay) only gates request identity and never
|
|
535
|
+
* substitutes for it, so a future incompatible format refuses loudly
|
|
536
|
+
* instead of being read as v1. Every documented header field (kind,
|
|
537
|
+
* v, an integer hashVersion, a date string recordedAt) and row field
|
|
538
|
+
* (adapterId, model, requestHash, request, caps, events, an optional
|
|
539
|
+
* string provider, an optional nonempty usageSemantics) is checked
|
|
540
|
+
* here, and the nested structures are validated in depth (v1.30.0
|
|
541
|
+
* review P3): the request must be a plain object, every event must
|
|
542
|
+
* be a member of the canonical ChatEvent vocabulary with its
|
|
543
|
+
* required payload and Usage numeric invariants, and caps must carry
|
|
544
|
+
* every ModelCaps field (with the optional pricing table checked
|
|
545
|
+
* when present). Unknown extra FIELDS are tolerated for forward
|
|
546
|
+
* compatibility. Event stream SEMANTICS (one trailing terminal per
|
|
547
|
+
* row) and adapter consistency across rows (provider,
|
|
548
|
+
* usageSemantics, caps agreement) are deliberately not checked at
|
|
549
|
+
* read time; `replay` enforces them before serving anything (v1.29.0
|
|
550
|
+
* review P3), so reading never blocks inspecting a well formed file.
|
|
393
551
|
* Parse and shape failures throw a typed ConfigError naming the
|
|
394
552
|
* cassette path and line (v1.28.0 review P3).
|
|
395
553
|
*/
|
|
@@ -409,11 +567,32 @@ function readCassette(path) {
|
|
|
409
567
|
const headerRaw = first === void 0 ? {} : parse(first);
|
|
410
568
|
if (headerRaw.kind !== "rulvar-vcr") throw new ConfigError(`${path} is not a rulvar VCR cassette`);
|
|
411
569
|
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`);
|
|
570
|
+
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)}`);
|
|
571
|
+
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
572
|
return {
|
|
413
573
|
header: headerRaw,
|
|
414
574
|
rows: numbered.slice(1).map((line) => {
|
|
415
|
-
const
|
|
416
|
-
|
|
575
|
+
const parsed = parse(line);
|
|
576
|
+
const reject = (what) => {
|
|
577
|
+
throw new ConfigError(`${path}:${String(line.lineNo)} is not a VCR row: ${what}`);
|
|
578
|
+
};
|
|
579
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) reject("each row is a JSON object");
|
|
580
|
+
const row = parsed;
|
|
581
|
+
if (typeof row.adapterId !== "string" || row.adapterId === "") reject("adapterId must be a nonempty string");
|
|
582
|
+
if (typeof row.requestHash !== "string" || row.requestHash === "") reject("requestHash must be a nonempty string");
|
|
583
|
+
if (typeof row.model !== "string" || row.model === "") reject("model must be a nonempty string");
|
|
584
|
+
if (row.provider !== void 0 && typeof row.provider !== "string") reject("provider, when present, must be a string");
|
|
585
|
+
if (row.usageSemantics !== void 0 && (typeof row.usageSemantics !== "string" || row.usageSemantics === "")) reject("usageSemantics, when present, must be a nonempty string");
|
|
586
|
+
if (typeof row.request !== "object" || row.request === null || Array.isArray(row.request)) reject("request must be an object (the redacted canonical request)");
|
|
587
|
+
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
|
+
const capsIssue = capsShapeIssue(row.caps);
|
|
589
|
+
if (capsIssue !== void 0) reject(capsIssue);
|
|
590
|
+
if (!Array.isArray(row.events)) reject("events must be an array");
|
|
591
|
+
const events = row.events;
|
|
592
|
+
for (const [index, event] of events.entries()) {
|
|
593
|
+
const eventIssue = eventShapeIssue(event, index);
|
|
594
|
+
if (eventIssue !== void 0) reject(eventIssue);
|
|
595
|
+
}
|
|
417
596
|
return row;
|
|
418
597
|
})
|
|
419
598
|
};
|
|
@@ -422,44 +601,104 @@ function readCassette(path) {
|
|
|
422
601
|
* Builds replay adapters from a cassette. `onMiss: 'throw'` is the
|
|
423
602
|
* hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
|
|
424
603
|
* matching live adapter in `adapters` (a development convenience only).
|
|
604
|
+
*
|
|
605
|
+
* Repeated hashes replay in file order (v1.29.0 review P2): rows
|
|
606
|
+
* sharing a `(adapterId, requestHash)` key form an ordered occurrence
|
|
607
|
+
* list, and every `stream()` call consumes exactly one occurrence,
|
|
608
|
+
* allocated synchronously inside the call itself, so two concurrent
|
|
609
|
+
* identical requests can never be served the same recorded exchange.
|
|
610
|
+
* A call after the last occurrence is a miss: under `onMiss: 'throw'`
|
|
611
|
+
* it raises a VcrMissError whose `recordedOccurrences` says the hash
|
|
612
|
+
* WAS recorded but is exhausted, and under `'passthrough'` it
|
|
613
|
+
* forwards to the live adapter exactly like a never-recorded request.
|
|
614
|
+
*
|
|
615
|
+
* Before serving anything, replay also enforces what `record` has
|
|
616
|
+
* guaranteed since v1.29.0: every row's event stream ends with
|
|
617
|
+
* exactly one terminal event (finish or error), and all caps
|
|
618
|
+
* snapshots for one `(adapterId, model)` agree, since the replay
|
|
619
|
+
* adapter can only report one caps truth per model. Violations throw
|
|
620
|
+
* a typed ConfigError naming the cassette and row.
|
|
621
|
+
*
|
|
622
|
+
* The rebuilt adapter restores the recorded provider and
|
|
623
|
+
* usageSemantics declarations (v1.30.0 review P2), so the fresh
|
|
624
|
+
* journal of a replayed run carries the same provenance stamp the
|
|
625
|
+
* recorded run got instead of silently reading like an entry from
|
|
626
|
+
* before the stamp existed. All rows of one adapter must agree on
|
|
627
|
+
* both declarations; a conflict refuses with a typed ConfigError
|
|
628
|
+
* before anything is served. A cassette recorded before v1.31.0
|
|
629
|
+
* stores no usageSemantics, so its replays stamp nothing (documented
|
|
630
|
+
* historical laxity).
|
|
425
631
|
*/
|
|
426
632
|
function replay(options) {
|
|
427
633
|
const { header, rows } = readCassette(options.cassette);
|
|
428
634
|
const oldestSupported = CURRENT_HASH_VERSION - 1;
|
|
429
|
-
if (
|
|
635
|
+
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`);
|
|
636
|
+
rows.forEach((row, index) => {
|
|
637
|
+
const terminals = row.events.filter((event) => isTerminalEvent(event)).length;
|
|
638
|
+
const last = row.events[row.events.length - 1];
|
|
639
|
+
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
|
+
});
|
|
430
641
|
const byAdapter = /* @__PURE__ */ new Map();
|
|
431
642
|
for (const row of rows) {
|
|
432
643
|
const forAdapter = byAdapter.get(row.adapterId) ?? /* @__PURE__ */ new Map();
|
|
433
|
-
forAdapter.
|
|
644
|
+
const occurrences = forAdapter.get(row.requestHash) ?? [];
|
|
645
|
+
occurrences.push(row);
|
|
646
|
+
forAdapter.set(row.requestHash, occurrences);
|
|
434
647
|
byAdapter.set(row.adapterId, forAdapter);
|
|
435
648
|
}
|
|
436
649
|
const live = new Map((options.adapters ?? []).map((adapter) => [adapter.id, adapter]));
|
|
437
650
|
return [.../* @__PURE__ */ new Set([...byAdapter.keys(), ...live.keys()])].map((adapterId) => {
|
|
438
651
|
const recorded = byAdapter.get(adapterId) ?? /* @__PURE__ */ new Map();
|
|
439
|
-
const
|
|
440
|
-
const someRow = [
|
|
652
|
+
const recordedRows = [...recorded.values()].flat();
|
|
653
|
+
const someRow = recordedRows[0];
|
|
441
654
|
const capsByModel = /* @__PURE__ */ new Map();
|
|
442
|
-
for (const row of
|
|
655
|
+
for (const row of recordedRows) {
|
|
656
|
+
const canonical = canonicalJson(row.caps);
|
|
657
|
+
const existing = capsByModel.get(row.model);
|
|
658
|
+
if (existing === void 0) capsByModel.set(row.model, {
|
|
659
|
+
caps: row.caps,
|
|
660
|
+
canonical
|
|
661
|
+
});
|
|
662
|
+
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`);
|
|
663
|
+
}
|
|
664
|
+
for (const field of ["provider", "usageSemantics"]) {
|
|
665
|
+
const values = [...new Set(recordedRows.map((row) => row[field]))];
|
|
666
|
+
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
|
+
}
|
|
668
|
+
const passthrough = live.get(adapterId);
|
|
669
|
+
const cursors = /* @__PURE__ */ new Map();
|
|
443
670
|
return {
|
|
444
671
|
id: adapterId,
|
|
445
672
|
...someRow?.provider === void 0 ? {} : { provider: someRow.provider },
|
|
673
|
+
...someRow?.usageSemantics === void 0 ? {} : { usageSemantics: someRow.usageSemantics },
|
|
446
674
|
caps: (model) => {
|
|
447
|
-
const snapshot = capsByModel.get(model) ?? passthrough?.caps(model);
|
|
675
|
+
const snapshot = capsByModel.get(model)?.caps ?? passthrough?.caps(model);
|
|
448
676
|
if (snapshot === void 0) throw new ConfigError(`VCR replay adapter '${adapterId}' has no caps snapshot for model '${model}'`);
|
|
449
677
|
return snapshot;
|
|
450
678
|
},
|
|
451
|
-
|
|
679
|
+
stream(req, signal) {
|
|
452
680
|
const hash = requestHash(req);
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
681
|
+
const occurrences = recorded.get(hash);
|
|
682
|
+
let row;
|
|
683
|
+
if (occurrences !== void 0) {
|
|
684
|
+
const cursor = cursors.get(hash) ?? 0;
|
|
685
|
+
if (cursor < occurrences.length) {
|
|
686
|
+
cursors.set(hash, cursor + 1);
|
|
687
|
+
row = occurrences[cursor];
|
|
688
|
+
}
|
|
461
689
|
}
|
|
462
|
-
|
|
690
|
+
const recordedCount = occurrences?.length;
|
|
691
|
+
return (async function* () {
|
|
692
|
+
if (row !== void 0) {
|
|
693
|
+
for (const event of row.events) yield event;
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (options.onMiss === "passthrough" && passthrough !== void 0) {
|
|
697
|
+
yield* passthrough.stream(req, signal);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
throw new VcrMissError(adapterId, hash, recordedCount);
|
|
701
|
+
})();
|
|
463
702
|
}
|
|
464
703
|
};
|
|
465
704
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/testing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.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.31.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^22.20.0",
|