From 72e642cfe00caa67f2b234adfcc4b7709cebd8a8 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Tue, 5 May 2026 15:16:20 -0700 Subject: [PATCH 1/3] feat: snapshot-style fixture recording by test ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When X-Test-Id is present, recorded fixtures save to //.json instead of timestamp-based filenames. Slugifier strips test file prefixes (.spec.ts, .test.tsx, .e2e.js), treats both Unicode › and ASCII > as hierarchy separators (double-dash). Multiple fixtures for same test+provider merge into one file. Falls back to timestamps when no testId or slug is empty. Closes #155 (@jantimon). --- src/helpers.ts | 16 ++++++++++++++ src/recorder.ts | 55 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/helpers.ts b/src/helpers.ts index 62f4399..bec3e32 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -619,6 +619,22 @@ export function getTestId(req: http.IncomingMessage): string { return "__default__"; } +// ─── Snapshot recording helpers ────────────────────────────────────────────── + +/** + * Convert a test ID (e.g. Playwright titlePath) into a filesystem-safe slug + * suitable for use as a directory name in snapshot-style recording. + */ +export function slugifyTestId(testId: string): string { + return testId + .replace(/^.*?\.(?:spec|test|e2e)\.(?:tsx|ts|jsx|js|mjs|cjs)(?=\s|›|$)\s*›?\s*/i, "") // strip test file extension prefix + .replace(/\s*[›>]\s*/g, "--") // Playwright titlePath separator → double dash + .replace(/[^\w-]/g, "-") // non-word chars → dash + .replace(/-{3,}/g, "--") // collapse 3+ dashes to double + .replace(/^-+|-+$/g, "") // trim leading/trailing dashes + .toLowerCase(); +} + // ─── Embedding helpers ───────────────────────────────────────────────────── const DEFAULT_EMBEDDING_DIMENSIONS = 1536; diff --git a/src/recorder.ts b/src/recorder.ts index 12a4db3..73c9de5 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -16,6 +16,7 @@ import type { Logger } from "./logger.js"; import { collapseStreamingResponse } from "./stream-collapse.js"; import { writeErrorResponse } from "./sse-writer.js"; import { resolveUpstreamUrl } from "./url.js"; +import { getTestId, slugifyTestId } from "./helpers.js"; /** Headers to strip when proxying — hop-by-hop (RFC 2616 §13.5.1) + client-set. */ const STRIP_HEADERS = new Set([ @@ -331,15 +332,41 @@ export async function proxyAndRecord( // In proxy-only mode, skip recording to disk and in-memory caching if (!defaults.record?.proxyOnly) { - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const filename = `${providerKey}-${timestamp}-${crypto.randomUUID().slice(0, 8)}.json`; - const filepath = path.join(fixturePath, filename); + // Determine file path: snapshot-style (by testId) or legacy timestamp + const testId = getTestId(req); + let isSnapshotMode = testId !== "__default__"; + + let filepath!: string; + let mergeExisting = false; + + if (isSnapshotMode) { + const slug = slugifyTestId(testId); + if (!slug) { + // Slug resolved to empty (e.g. testId was all punctuation) — fall back + // to timestamp-based recording so we still capture the fixture. + isSnapshotMode = false; + } else { + const dir = path.join(fixturePath, slug); + filepath = path.join(dir, `${providerKey}.json`); + mergeExisting = true; + } + } + + if (!isSnapshotMode) { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `${providerKey}-${timestamp}-${crypto.randomUUID().slice(0, 8)}.json`; + filepath = path.join(fixturePath, filename); + } let writtenToDisk = false; try { - // Ensure fixture directory exists - fs.mkdirSync(fixturePath, { recursive: true }); - + // Create the target directory (must be inside try/catch so filesystem + // errors don't prevent the upstream response from being relayed). + if (isSnapshotMode) { + fs.mkdirSync(path.dirname(filepath), { recursive: true }); + } else { + fs.mkdirSync(fixturePath, { recursive: true }); + } // Collect warnings for the fixture file const warnings: string[] = []; if (isEmptyMatch) { @@ -353,10 +380,24 @@ export async function proxyAndRecord( // NOTE: the persisted fixture is always the real upstream response, even when chaos // later mutates the relay (e.g. malformed via beforeWriteResponse). Chaos is a live-traffic // decoration; the recorded artifact must stay truthful so replay sees what upstream said. - const fileContent: Record = { fixtures: [fixture] }; + let fileContent: { fixtures: unknown[]; _warning?: string }; + + if (mergeExisting && fs.existsSync(filepath)) { + try { + const existing = JSON.parse(fs.readFileSync(filepath, "utf-8")); + fileContent = { fixtures: [...(existing.fixtures ?? []), fixture] }; + } catch { + // Corrupted file — overwrite + fileContent = { fixtures: [fixture] }; + } + } else { + fileContent = { fixtures: [fixture] }; + } + if (warnings.length > 0) { fileContent._warning = warnings.join("; "); } + fs.writeFileSync(filepath, JSON.stringify(fileContent, null, 2), "utf-8"); writtenToDisk = true; } catch (err) { From 29c6e9f4f190af839957d3388ff81cff5756f375 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Tue, 5 May 2026 15:16:27 -0700 Subject: [PATCH 2/3] test: snapshot recording unit and integration tests 21 tests covering slugifyTestId (13 unit: separators, special chars, file extension stripping, ASCII > handling, edge cases) and snapshot integration (8: directory creation, merge, isolation, timestamp fallback, append-on-rerun, corrupted file recovery, empty slug fallback). --- src/__tests__/snapshot-recording.test.ts | 431 +++++++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 src/__tests__/snapshot-recording.test.ts diff --git a/src/__tests__/snapshot-recording.test.ts b/src/__tests__/snapshot-recording.test.ts new file mode 100644 index 0000000..260dbc9 --- /dev/null +++ b/src/__tests__/snapshot-recording.test.ts @@ -0,0 +1,431 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as http from "node:http"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { Fixture, FixtureFile } from "../types.js"; +import { createServer, type ServerInstance } from "../server.js"; +import { slugifyTestId } from "../helpers.js"; + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +function post( + url: string, + body: unknown, + headers?: Record, +): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const parsed = new URL(url); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(data), + ...headers, + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString(), + }); + }); + }, + ); + req.on("error", reject); + req.write(data); + req.end(); + }); +} + +// --------------------------------------------------------------------------- +// Test state +// --------------------------------------------------------------------------- + +let upstream: ServerInstance | undefined; +let recorder: ServerInstance | undefined; +let tmpDir: string | undefined; + +afterEach(async () => { + if (recorder) { + await new Promise((resolve) => recorder!.server.close(() => resolve())); + recorder = undefined; + } + if (upstream) { + await new Promise((resolve) => upstream!.server.close(() => resolve())); + upstream = undefined; + } + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } +}); + +// --------------------------------------------------------------------------- +// Helper: set up upstream (real API mock) + recording proxy +// --------------------------------------------------------------------------- + +async function setupUpstreamAndRecorder( + upstreamFixtures: Fixture[], +): Promise<{ upstreamUrl: string; recorderUrl: string; fixturePath: string }> { + upstream = await createServer(upstreamFixtures, { port: 0 }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-snapshot-")); + recorder = await createServer([], { + port: 0, + logLevel: "silent", + record: { providers: { openai: upstream.url }, fixturePath: tmpDir }, + }); + return { upstreamUrl: upstream.url, recorderUrl: recorder.url, fixturePath: tmpDir }; +} + +// --------------------------------------------------------------------------- +// Unit tests — slugifyTestId +// --------------------------------------------------------------------------- + +describe("slugifyTestId", () => { + it("converts Playwright titlePath separator to double dash", () => { + expect(slugifyTestId("agent chat › handles tool call")).toBe("agent-chat--handles-tool-call"); + }); + + it("handles simple space-separated string", () => { + expect(slugifyTestId("simple test")).toBe("simple-test"); + }); + + it("replaces special characters with dashes", () => { + // "Test with 'quotes' & specials!" → + // non-word → dash: "Test-with--quotes---specials-" + // 3+ dashes → double: "Test-with--quotes--specials-" + // trim trailing: "Test-with--quotes--specials" + // lowercase: "test-with--quotes--specials" + expect(slugifyTestId("Test with 'quotes' & specials!")).toBe("test-with--quotes--specials"); + const result = slugifyTestId("Test with 'quotes' & specials!"); + expect(result).not.toMatch(/^-/); + expect(result).not.toMatch(/-$/); + }); + + it("collapses 3+ consecutive dashes to double dash", () => { + expect(slugifyTestId("a---b")).toBe("a--b"); + expect(slugifyTestId("a----b")).toBe("a--b"); + }); + + it("trims leading and trailing dashes", () => { + expect(slugifyTestId("-hello-")).toBe("hello"); + expect(slugifyTestId("---hello---")).toBe("hello"); + }); + + it("lowercases the result", () => { + expect(slugifyTestId("MyTest")).toBe("mytest"); + expect(slugifyTestId("UPPER CASE")).toBe("upper-case"); + }); + + it("handles underscores (word chars) as-is", () => { + expect(slugifyTestId("my_test_case")).toBe("my_test_case"); + }); + + it("handles empty string", () => { + expect(slugifyTestId("")).toBe(""); + }); + + it("strips .spec.ts prefix from Playwright titlePath", () => { + expect(slugifyTestId("my-app.spec.ts › greeting › handles tool call")).toBe( + "greeting--handles-tool-call", + ); + }); + + it("strips .test.tsx prefix", () => { + expect(slugifyTestId("components.test.tsx › Button › renders correctly")).toBe( + "button--renders-correctly", + ); + }); + + it("strips .e2e.js prefix", () => { + expect(slugifyTestId("flow.e2e.js › checkout")).toBe("checkout"); + }); + + it("handles testId with no file extension prefix", () => { + expect(slugifyTestId("greeting › handles tool call")).toBe("greeting--handles-tool-call"); + }); + + it("treats ASCII > the same as Unicode ›", () => { + expect(slugifyTestId("greeting > handles tool call")).toBe("greeting--handles-tool-call"); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests — snapshot-style recording +// --------------------------------------------------------------------------- + +describe("snapshot-style recording", () => { + it("creates fixture in testId-based directory when X-Test-Id is present", async () => { + const { recorderUrl, fixturePath } = await setupUpstreamAndRecorder([ + { + match: { userMessage: "capital of France" }, + response: { content: "Paris is the capital of France." }, + }, + ]); + + // Use ASCII-safe testId for the integration test — Node http.request rejects + // non-ASCII header values. The slugifyTestId unit tests above cover the full + // Unicode "›" separator handling. + await post( + `${recorderUrl}/v1/chat/completions`, + { + model: "gpt-4", + messages: [{ role: "user", content: "What is the capital of France?" }], + }, + { "x-test-id": "agent chat - handles tool call" }, + ); + + const slugDir = path.join(fixturePath, "agent-chat--handles-tool-call"); + expect(fs.existsSync(slugDir)).toBe(true); + expect(fs.existsSync(path.join(slugDir, "openai.json"))).toBe(true); + + const content = JSON.parse( + fs.readFileSync(path.join(slugDir, "openai.json"), "utf-8"), + ) as FixtureFile; + expect(content.fixtures).toHaveLength(1); + expect(content.fixtures[0].match.userMessage).toBe("What is the capital of France?"); + }); + + it("merges multiple fixtures into the same file for the same testId", async () => { + const { recorderUrl, fixturePath } = await setupUpstreamAndRecorder([ + { + match: { userMessage: "capital of France" }, + response: { content: "Paris is the capital of France." }, + }, + { + match: { userMessage: "capital of Germany" }, + response: { content: "Berlin is the capital of Germany." }, + }, + ]); + + const testId = "multi-turn test"; + + // First request + await post( + `${recorderUrl}/v1/chat/completions`, + { + model: "gpt-4", + messages: [{ role: "user", content: "What is the capital of France?" }], + }, + { "x-test-id": testId }, + ); + + // Second request with same testId but different message + await post( + `${recorderUrl}/v1/chat/completions`, + { + model: "gpt-4", + messages: [{ role: "user", content: "What is the capital of Germany?" }], + }, + { "x-test-id": testId }, + ); + + const slugDir = path.join(fixturePath, "multi-turn-test"); + const content = JSON.parse( + fs.readFileSync(path.join(slugDir, "openai.json"), "utf-8"), + ) as FixtureFile; + expect(content.fixtures).toHaveLength(2); + expect(content.fixtures[0].match.userMessage).toBe("What is the capital of France?"); + expect(content.fixtures[1].match.userMessage).toBe("What is the capital of Germany?"); + }); + + it("creates separate directories for different testIds", async () => { + const { recorderUrl, fixturePath } = await setupUpstreamAndRecorder([ + { + match: { userMessage: "capital of France" }, + response: { content: "Paris is the capital of France." }, + }, + { + match: { userMessage: "capital of Germany" }, + response: { content: "Berlin is the capital of Germany." }, + }, + ]); + + await post( + `${recorderUrl}/v1/chat/completions`, + { + model: "gpt-4", + messages: [{ role: "user", content: "What is the capital of France?" }], + }, + { "x-test-id": "test-one" }, + ); + + await post( + `${recorderUrl}/v1/chat/completions`, + { + model: "gpt-4", + messages: [{ role: "user", content: "What is the capital of Germany?" }], + }, + { "x-test-id": "test-two" }, + ); + + expect(fs.existsSync(path.join(fixturePath, "test-one", "openai.json"))).toBe(true); + expect(fs.existsSync(path.join(fixturePath, "test-two", "openai.json"))).toBe(true); + }); + + it("falls back to timestamp-based filename when no X-Test-Id is present", async () => { + const { recorderUrl, fixturePath } = await setupUpstreamAndRecorder([ + { + match: { userMessage: "capital of France" }, + response: { content: "Paris is the capital of France." }, + }, + ]); + + await post(`${recorderUrl}/v1/chat/completions`, { + model: "gpt-4", + messages: [{ role: "user", content: "What is the capital of France?" }], + }); + + // Should be in the root fixturePath with timestamp pattern, not in a subdirectory + const files = fs.readdirSync(fixturePath); + const timestampFiles = files.filter((f) => f.startsWith("openai-") && f.endsWith(".json")); + expect(timestampFiles).toHaveLength(1); + }); + + it("appends to existing fixture file on re-run", async () => { + const { recorderUrl, fixturePath } = await setupUpstreamAndRecorder([ + { + match: { userMessage: "capital of France" }, + response: { content: "Paris is the capital of France." }, + }, + ]); + + const testId = "re-run-test"; + const slugDir = path.join(fixturePath, "re-run-test"); + fs.mkdirSync(slugDir, { recursive: true }); + + // Write an existing fixture file manually + const existingFixture = { + fixtures: [ + { + match: { userMessage: "What is 2+2?" }, + response: { content: "4" }, + }, + ], + }; + fs.writeFileSync( + path.join(slugDir, "openai.json"), + JSON.stringify(existingFixture, null, 2), + "utf-8", + ); + + // Record a new fixture with the same testId + await post( + `${recorderUrl}/v1/chat/completions`, + { + model: "gpt-4", + messages: [{ role: "user", content: "What is the capital of France?" }], + }, + { "x-test-id": testId }, + ); + + const content = JSON.parse( + fs.readFileSync(path.join(slugDir, "openai.json"), "utf-8"), + ) as FixtureFile; + + // Should have both the pre-existing fixture AND the newly recorded one + expect(content.fixtures).toHaveLength(2); + expect(content.fixtures[0].match.userMessage).toBe("What is 2+2?"); + expect(content.fixtures[1].match.userMessage).toBe("What is the capital of France?"); + }); + + it("falls back to timestamp recording when slugified testId is empty", async () => { + const { recorderUrl, fixturePath } = await setupUpstreamAndRecorder([ + { + match: { userMessage: "capital of France" }, + response: { content: "Paris is the capital of France." }, + }, + ]); + + // "." slugifies to "" (all non-word chars become dashes, then trimmed) + await post( + `${recorderUrl}/v1/chat/completions`, + { + model: "gpt-4", + messages: [{ role: "user", content: "What is the capital of France?" }], + }, + { "x-test-id": "." }, + ); + + // Should NOT create a subdirectory — should fall back to timestamp-based + // filename in the root fixturePath + const entries = fs.readdirSync(fixturePath); + const timestampFiles = entries.filter((f) => f.startsWith("openai-") && f.endsWith(".json")); + expect(timestampFiles).toHaveLength(1); + + // No subdirectories should have been created + const dirs = entries.filter((e) => fs.statSync(path.join(fixturePath, e)).isDirectory()); + expect(dirs).toHaveLength(0); + }); + + it("falls back to timestamp recording when testId is all dashes", async () => { + const { recorderUrl, fixturePath } = await setupUpstreamAndRecorder([ + { + match: { userMessage: "capital of France" }, + response: { content: "Paris is the capital of France." }, + }, + ]); + + // "---" slugifies to "" (dashes are trimmed from both ends) + await post( + `${recorderUrl}/v1/chat/completions`, + { + model: "gpt-4", + messages: [{ role: "user", content: "What is the capital of France?" }], + }, + { "x-test-id": "---" }, + ); + + const entries = fs.readdirSync(fixturePath); + const timestampFiles = entries.filter((f) => f.startsWith("openai-") && f.endsWith(".json")); + expect(timestampFiles).toHaveLength(1); + + const dirs = entries.filter((e) => fs.statSync(path.join(fixturePath, e)).isDirectory()); + expect(dirs).toHaveLength(0); + }); + + it("handles corrupted existing fixture file by overwriting", async () => { + const { recorderUrl, fixturePath } = await setupUpstreamAndRecorder([ + { + match: { userMessage: "capital of France" }, + response: { content: "Paris is the capital of France." }, + }, + ]); + + const testId = "corrupted-test"; + const slugDir = path.join(fixturePath, "corrupted-test"); + fs.mkdirSync(slugDir, { recursive: true }); + + // Write a corrupted file + fs.writeFileSync(path.join(slugDir, "openai.json"), "{ not valid json", "utf-8"); + + await post( + `${recorderUrl}/v1/chat/completions`, + { + model: "gpt-4", + messages: [{ role: "user", content: "What is the capital of France?" }], + }, + { "x-test-id": testId }, + ); + + const content = JSON.parse( + fs.readFileSync(path.join(slugDir, "openai.json"), "utf-8"), + ) as FixtureFile; + + // Should have only the new fixture (corrupted file was overwritten) + expect(content.fixtures).toHaveLength(1); + expect(content.fixtures[0].match.userMessage).toBe("What is the capital of France?"); + }); +}); From e1d4604c36fe1afd57c6eaf96405ad3758b48f7e Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Tue, 5 May 2026 15:16:34 -0700 Subject: [PATCH 3/3] docs: snapshot-style recording documentation and changelog Dedicated section in record-replay docs. Info-box on fixtures page. Snapshot example on examples page with Playwright setup and directory layout. Changelog credits @jantimon (issue #155). --- CHANGELOG.md | 6 +++ docs/examples/index.html | 36 +++++++++++++++ docs/fixtures/index.html | 10 +++++ docs/record-replay/index.html | 82 +++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd9296b..7d8a9d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @copilotkit/aimock +## [Unreleased] + +### Added + +- **Snapshot-style recording** — When `X-Test-Id` is present, recorded fixtures are saved to `//.json` instead of timestamp-based filenames. Multiple fixtures for the same test+provider merge into one file. Stable paths enable meaningful PR diffs and easy test-to-fixture mapping. (Feature request by @jantimon, issue #155) + ## [1.18.0] - 2026-05-04 ### Added diff --git a/docs/examples/index.html b/docs/examples/index.html index 6336761..4b32b7f 100644 --- a/docs/examples/index.html +++ b/docs/examples/index.html @@ -368,6 +368,42 @@

Record & Replay

} +

Snapshot Recording (per-test fixtures)

+

+ Send X-Test-Id from your test runner to organize recorded fixtures into + per-test directories. Here is a Playwright example: +

+
+
playwright/setup.ts ts
+
import { test } from "@playwright/test";
+
+test.beforeEach(async ({ page }, testInfo) => {
+  // Route all LLM traffic through aimock with a test ID header
+  await page.setExtraHTTPHeaders({
+    "X-Test-Id": testInfo.title,
+  });
+});
+
+

+ The resulting fixture directory layout groups each test’s recordings by provider: +

+
+
+ Recorded fixture tree text +
+
fixtures/recorded/
+  should-greet-the-user/
+    openai.json
+    anthropic.json
+  should-handle-tool-calls/
+    openai.json
+
+

+ See + Snapshot-Style Recording for the + full workflow, including replay and drift detection. +

+

Full Suite

diff --git a/docs/fixtures/index.html b/docs/fixtures/index.html index 1c4fe0c..2bea28f 100644 --- a/docs/fixtures/index.html +++ b/docs/fixtures/index.html @@ -493,6 +493,16 @@

From a directory

mock.loadFixtureDir("./fixtures"); +
+

+ Snapshot-style recording: When recording with X-Test-Id, + fixtures are automatically organized into per-test directories + (<fixturePath>/<test-slug>/<provider>.json). See + Snapshot-Style Recording for + details. +

+
+

Programmatically

programmatic.ts ts
diff --git a/docs/record-replay/index.html b/docs/record-replay/index.html index 618f215..3f380fb 100644 --- a/docs/record-replay/index.html +++ b/docs/record-replay/index.html @@ -465,6 +465,88 @@

Fixture Auto-Generation

fixture is saved to disk with a warning but not registered in memory.

+

Snapshot-Style Recording

+

+ When the X-Test-Id header is present on a request, aimock uses + snapshot-style recording instead of the default timestamp-based + filenames. Fixtures are organized by test, producing stable file paths that work well with + version control and PR diffs. +

+ +

Directory structure

+

+ The test ID is slugified into a directory name, and each provider gets its own file within + that directory: +

+ +
+
+ Snapshot directory layout text +
+
fixtures/recorded/
+  agent-chat--handles-tool-call/
+    openai.json        # All OpenAI fixtures for this test
+    anthropic.json     # All Anthropic fixtures for this test
+  simple-test/
+    openai.json
+
+ +

+ The slugify rules: Common test file prefixes (.spec.ts, + .test.tsx, .e2e.js, etc.) are automatically stripped from the + test ID before slugifying, so my-app.spec.ts › greeting becomes + greeting. Then Playwright's  ›  separator becomes + --, non-word characters become -, runs of 3+ dashes collapse to + --, and the result is lowercased. For example, + "agent chat › handles tool call" becomes + agent-chat--handles-tool-call. +

+ +

Merge behavior on re-run

+

+ When you re-run a test, the new fixture is appended to the existing + <provider>.json file rather than overwriting it. This preserves + multi-turn conversations in a single file. If the existing file is corrupted (invalid + JSON), it is silently replaced. +

+ +

Sending X-Test-Id from test frameworks

+ +
+
Playwright ts
+
// Playwright exposes testInfo.titlePath which joins suite + test titles
+import { test } from "@playwright/test";
+
+test("handles tool call", async ({ page }, testInfo) => {
+  // titlePath = ["agent chat", "handles tool call"]
+  const testId = testInfo.titlePath.join(" › ");
+  // Set on your OpenAI/Anthropic client config as a default header:
+  // headers: { "X-Test-Id": testId }
+});
+
+ +
+
Vitest ts
+
import { describe, it } from "vitest";
+
+describe("agent chat", () => {
+  it("handles tool call", async () => {
+    // Pass X-Test-Id on each LLM request:
+    const resp = await fetch("http://localhost:4010/v1/chat/completions", {
+      headers: { "X-Test-Id": "agent chat › handles tool call" },
+      // ...body
+    });
+  });
+});
+
+ +

Fallback behavior

+

+ When no X-Test-Id header is present (or the value is + __default__), recording falls back to the standard timestamp-based filename: + <provider>-<timestamp>-<uuid>.json. +

+

Fixture Lifecycle