@rulvar/testing 1.30.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 +35 -7
- package/dist/index.js +170 -9
- 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;
|
|
@@ -199,14 +209,22 @@ interface VcrCassette {
|
|
|
199
209
|
* checked by replay) only gates request identity and never
|
|
200
210
|
* substitutes for it, so a future incompatible format refuses loudly
|
|
201
211
|
* instead of being read as v1. Every documented header field (kind,
|
|
202
|
-
* v, an integer hashVersion, a date
|
|
212
|
+
* v, an integer hashVersion, a date string recordedAt) and row field
|
|
203
213
|
* (adapterId, model, requestHash, request, caps, events, an optional
|
|
204
|
-
* string provider
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
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.
|
|
226
|
+
* Parse and shape failures throw a typed ConfigError naming the
|
|
227
|
+
* cassette path and line (v1.28.0 review P3).
|
|
210
228
|
*/
|
|
211
229
|
declare function readCassette(path: string): VcrCassette;
|
|
212
230
|
/**
|
|
@@ -230,6 +248,16 @@ declare function readCassette(path: string): VcrCassette;
|
|
|
230
248
|
* snapshots for one `(adapterId, model)` agree, since the replay
|
|
231
249
|
* adapter can only report one caps truth per model. Violations throw
|
|
232
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).
|
|
233
261
|
*/
|
|
234
262
|
declare function replay(options: {
|
|
235
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
|
|
@@ -367,6 +367,7 @@ function record(options) {
|
|
|
367
367
|
const row = {
|
|
368
368
|
adapterId: adapter.id,
|
|
369
369
|
...adapter.provider === void 0 ? {} : { provider: adapter.provider },
|
|
370
|
+
...adapter.usageSemantics === void 0 ? {} : { usageSemantics: adapter.usageSemantics },
|
|
370
371
|
requestHash: requestHash(req),
|
|
371
372
|
request: walkStrings(JSON.parse(JSON.stringify(req)), redact),
|
|
372
373
|
events: walkStrings(JSON.parse(JSON.stringify(events)), redact),
|
|
@@ -397,6 +398,135 @@ var VcrMissError = class extends Error {
|
|
|
397
398
|
if (recordedOccurrences !== void 0) this.recordedOccurrences = recordedOccurrences;
|
|
398
399
|
}
|
|
399
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
|
+
}
|
|
400
530
|
/**
|
|
401
531
|
* Parses a cassette file (one header line plus one JSON row per line).
|
|
402
532
|
* The header must declare cassette format `v: 1`: the format version
|
|
@@ -404,14 +534,22 @@ var VcrMissError = class extends Error {
|
|
|
404
534
|
* checked by replay) only gates request identity and never
|
|
405
535
|
* substitutes for it, so a future incompatible format refuses loudly
|
|
406
536
|
* instead of being read as v1. Every documented header field (kind,
|
|
407
|
-
* v, an integer hashVersion, a date
|
|
537
|
+
* v, an integer hashVersion, a date string recordedAt) and row field
|
|
408
538
|
* (adapterId, model, requestHash, request, caps, events, an optional
|
|
409
|
-
* string provider
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
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.
|
|
551
|
+
* Parse and shape failures throw a typed ConfigError naming the
|
|
552
|
+
* cassette path and line (v1.28.0 review P3).
|
|
415
553
|
*/
|
|
416
554
|
function readCassette(path) {
|
|
417
555
|
const numbered = readFileSync(path, "utf8").split("\n").map((text, index) => ({
|
|
@@ -444,9 +582,17 @@ function readCassette(path) {
|
|
|
444
582
|
if (typeof row.requestHash !== "string" || row.requestHash === "") reject("requestHash must be a nonempty string");
|
|
445
583
|
if (typeof row.model !== "string" || row.model === "") reject("model must be a nonempty string");
|
|
446
584
|
if (row.provider !== void 0 && typeof row.provider !== "string") reject("provider, when present, must be a string");
|
|
447
|
-
if (typeof row.
|
|
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)");
|
|
448
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);
|
|
449
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
|
+
}
|
|
450
596
|
return row;
|
|
451
597
|
})
|
|
452
598
|
};
|
|
@@ -472,6 +618,16 @@ function readCassette(path) {
|
|
|
472
618
|
* snapshots for one `(adapterId, model)` agree, since the replay
|
|
473
619
|
* adapter can only report one caps truth per model. Violations throw
|
|
474
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).
|
|
475
631
|
*/
|
|
476
632
|
function replay(options) {
|
|
477
633
|
const { header, rows } = readCassette(options.cassette);
|
|
@@ -505,11 +661,16 @@ function replay(options) {
|
|
|
505
661
|
});
|
|
506
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`);
|
|
507
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
|
+
}
|
|
508
668
|
const passthrough = live.get(adapterId);
|
|
509
669
|
const cursors = /* @__PURE__ */ new Map();
|
|
510
670
|
return {
|
|
511
671
|
id: adapterId,
|
|
512
672
|
...someRow?.provider === void 0 ? {} : { provider: someRow.provider },
|
|
673
|
+
...someRow?.usageSemantics === void 0 ? {} : { usageSemantics: someRow.usageSemantics },
|
|
513
674
|
caps: (model) => {
|
|
514
675
|
const snapshot = capsByModel.get(model)?.caps ?? passthrough?.caps(model);
|
|
515
676
|
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.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",
|