@rulvar/testing 1.28.0 → 1.29.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 CHANGED
@@ -161,9 +161,14 @@ declare function defaultRedact(value: string): string;
161
161
  */
162
162
  declare function requestHash(req: ChatRequest): string;
163
163
  /**
164
- * Wraps live adapters for recording: every completed stream appends one
165
- * redacted row to the cassette JSONL. The wrapped adapters are drop-in:
166
- * same ids, providers, caps, and event streams.
164
+ * Wraps live adapters for recording: every stream that completes with
165
+ * exactly one terminal event (finish or error) appends one redacted
166
+ * row to the cassette JSONL. A stream that ends without a terminal
167
+ * (a requested abort or a truncated read), throws, or violates the
168
+ * adapter contract (a second terminal, data after the terminal)
169
+ * appends nothing, so a cassette row is always the record of one
170
+ * completed exchange (v1.28.0 review P2). The wrapped adapters are
171
+ * drop-in: same ids, providers, caps, and event streams.
167
172
  */
168
173
  declare function record(options: {
169
174
  adapters: ProviderAdapter[];
@@ -179,7 +184,15 @@ interface VcrCassette {
179
184
  header: VcrHeader;
180
185
  rows: VcrRow[];
181
186
  }
182
- /** Parses a cassette file (one header line plus one JSON row per line). */
187
+ /**
188
+ * Parses a cassette file (one header line plus one JSON row per line).
189
+ * The header must declare cassette format `v: 1`: the format version
190
+ * gates parsing itself, while hashVersion (checked by replay) only
191
+ * gates request identity and never substitutes for it, so a future
192
+ * incompatible format refuses loudly instead of being read as v1.
193
+ * Parse and shape failures throw a typed ConfigError naming the
194
+ * cassette path and line (v1.28.0 review P3).
195
+ */
183
196
  declare function readCassette(path: string): VcrCassette;
184
197
  /**
185
198
  * Builds replay adapters from a cassette. `onMiss: 'throw'` is the
package/dist/index.js CHANGED
@@ -326,9 +326,14 @@ function headerLine() {
326
326
  });
327
327
  }
328
328
  /**
329
- * Wraps live adapters for recording: every completed stream appends one
330
- * redacted row to the cassette JSONL. The wrapped adapters are drop-in:
331
- * same ids, providers, caps, and event streams.
329
+ * Wraps live adapters for recording: every stream that completes with
330
+ * exactly one terminal event (finish or error) appends one redacted
331
+ * row to the cassette JSONL. A stream that ends without a terminal
332
+ * (a requested abort or a truncated read), throws, or violates the
333
+ * adapter contract (a second terminal, data after the terminal)
334
+ * appends nothing, so a cassette row is always the record of one
335
+ * completed exchange (v1.28.0 review P2). The wrapped adapters are
336
+ * drop-in: same ids, providers, caps, and event streams.
332
337
  */
333
338
  function record(options) {
334
339
  const redact = options.redact ? (value) => defaultRedact(options.redact ? options.redact(value) : value) : defaultRedact;
@@ -341,8 +346,12 @@ function record(options) {
341
346
  async *stream(req, signal) {
342
347
  const events = [];
343
348
  let thrown = false;
349
+ let terminals = 0;
350
+ let postTerminal = false;
344
351
  try {
345
352
  for await (const event of adapter.stream(req, signal)) {
353
+ if (terminals > 0) postTerminal = true;
354
+ if (event.type === "finish" || event.type === "error") terminals += 1;
346
355
  events.push(event);
347
356
  yield event;
348
357
  }
@@ -350,7 +359,7 @@ function record(options) {
350
359
  thrown = true;
351
360
  throw error;
352
361
  } finally {
353
- if (!thrown) {
362
+ if (!thrown && terminals === 1 && !postTerminal) {
354
363
  const row = {
355
364
  adapterId: adapter.id,
356
365
  ...adapter.provider === void 0 ? {} : { provider: adapter.provider },
@@ -375,14 +384,38 @@ var VcrMissError = class extends Error {
375
384
  this.requestHash = hash;
376
385
  }
377
386
  };
378
- /** Parses a cassette file (one header line plus one JSON row per line). */
387
+ /**
388
+ * Parses a cassette file (one header line plus one JSON row per line).
389
+ * The header must declare cassette format `v: 1`: the format version
390
+ * gates parsing itself, while hashVersion (checked by replay) only
391
+ * gates request identity and never substitutes for it, so a future
392
+ * incompatible format refuses loudly instead of being read as v1.
393
+ * Parse and shape failures throw a typed ConfigError naming the
394
+ * cassette path and line (v1.28.0 review P3).
395
+ */
379
396
  function readCassette(path) {
380
- const lines = readFileSync(path, "utf8").split("\n").filter((line) => line.trim() !== "");
381
- const header = JSON.parse(lines[0] ?? "{}");
382
- if (header.kind !== "rulvar-vcr") throw new ConfigError(`${path} is not a rulvar VCR cassette`);
397
+ const numbered = readFileSync(path, "utf8").split("\n").map((text, index) => ({
398
+ text,
399
+ lineNo: index + 1
400
+ })).filter(({ text }) => text.trim() !== "");
401
+ const parse = (line) => {
402
+ try {
403
+ return JSON.parse(line.text);
404
+ } catch {
405
+ throw new ConfigError(`${path}:${String(line.lineNo)} is not valid JSON; the cassette is corrupt or truncated`);
406
+ }
407
+ };
408
+ const first = numbered[0];
409
+ const headerRaw = first === void 0 ? {} : parse(first);
410
+ if (headerRaw.kind !== "rulvar-vcr") throw new ConfigError(`${path} is not a rulvar VCR cassette`);
411
+ 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`);
383
412
  return {
384
- header,
385
- rows: lines.slice(1).map((line) => JSON.parse(line))
413
+ header: headerRaw,
414
+ rows: numbered.slice(1).map((line) => {
415
+ const row = parse(line);
416
+ if (typeof row !== "object" || row === null || typeof row.adapterId !== "string" || typeof row.requestHash !== "string" || !Array.isArray(row.events)) throw new ConfigError(`${path}:${String(line.lineNo)} is not a VCR row (adapterId, requestHash, and events are required)`);
417
+ return row;
418
+ })
386
419
  };
387
420
  }
388
421
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/testing",
3
- "version": "1.28.0",
3
+ "version": "1.29.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.28.0"
29
+ "@rulvar/core": "1.29.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.20.0",