diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index 134c7b316b..09bb3af5a5 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -160,9 +160,9 @@ const program = Effect.gen(function* () { ## Inspecting cassettes programmatically -`Cassette.Service` exposes `read`, `write`, `append`, `exists`, `list`, and -`scan` (re-running the secret detector over an existing cassette). Useful -for CI checks: +`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read` +returns the recorded interactions for a name; the file format is hidden +behind the seam. Useful for CI checks: ```ts import { HttpRecorder } from "@opencode-ai/http-recorder" @@ -170,13 +170,22 @@ import { Effect } from "effect" const audit = Effect.gen(function* () { const cassettes = yield* HttpRecorder.Cassette.Service - const findings = yield* Effect.forEach(yield* cassettes.list(), (entry) => - cassettes.read(entry.name).pipe(Effect.map((c) => ({ entry, findings: cassettes.scan(c) }))), + const entries = yield* cassettes.list() + const issues = yield* Effect.forEach(entries, (entry) => + cassettes.read(entry.name).pipe( + Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) })), + ), ) - return findings.filter((r) => r.findings.length > 0) + return issues.filter((i) => i.findings.length > 0) }) ``` +`cassetteLayer` is the batteries-included entry point — it provides +`Cassette.fileSystem({ directory })` automatically. If you want to provide +your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's +own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` / +`Cassette.memory` yourself. + ## Options reference ```ts diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts index bf7c6ae998..4e574da628 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette.ts @@ -1,62 +1,80 @@ -import { Context, Effect, FileSystem, Layer, PlatformError } from "effect" +import { Context, Data, Effect, FileSystem, Layer } from "effect" +import * as fs from "node:fs" import * as path from "node:path" -import { cassetteSecretFindings, secretFindings, type SecretFinding } from "./redaction" -import type { Cassette, CassetteMetadata, Interaction } from "./schema" -import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage" +import { secretFindings, type SecretFinding } from "./redaction" +import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema" -export interface Entry { - readonly name: string - readonly path: string +const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") + +export class CassetteNotFoundError extends Data.TaggedError("CassetteNotFoundError")<{ + readonly cassetteName: string +}> { + override get message() { + return `Cassette "${this.cassetteName}" not found` + } +} + +export interface AppendResult { + readonly findings: ReadonlyArray } export interface Interface { - readonly path: (name: string) => string - readonly read: (name: string) => Effect.Effect - readonly write: (name: string, cassette: Cassette) => Effect.Effect + readonly read: (name: string) => Effect.Effect, CassetteNotFoundError> readonly append: ( name: string, interaction: Interaction, - metadata: CassetteMetadata | undefined, - ) => Effect.Effect< - { - readonly cassette: Cassette - readonly findings: ReadonlyArray - }, - PlatformError.PlatformError - > + metadata?: CassetteMetadata, + ) => Effect.Effect readonly exists: (name: string) => Effect.Effect - readonly list: () => Effect.Effect, PlatformError.PlatformError> - readonly scan: (cassette: Cassette) => ReadonlyArray + readonly list: () => Effect.Effect> } export class Service extends Context.Service()("@opencode-ai/http-recorder/Cassette") {} -export const layer = (options: { readonly directory?: string } = {}) => +export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => + fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`)) + +const buildCassette = ( + name: string, + interactions: ReadonlyArray, + metadata: CassetteMetadata | undefined, +): Cassette => ({ + version: 1, + metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) }, + interactions, +}) + +const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` + +const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw)) + +export const fileSystem = ( + options: { readonly directory?: string } = {}, +): Layer.Layer => Layer.effect( Service, Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem + const fs = yield* FileSystem.FileSystem const directory = options.directory ?? DEFAULT_RECORDINGS_DIR const recorded = new Map() const directoriesEnsured = new Set() - const pathFor = (name: string) => cassettePath(name, directory) + const cassettePath = (name: string) => path.join(directory, `${name}.json`) - const ensureDirectory = Effect.fn("Cassette.ensureDirectory")(function* (name: string) { - const dir = path.dirname(pathFor(name)) - if (directoriesEnsured.has(dir)) return - yield* fileSystem.makeDirectory(dir, { recursive: true }) - directoriesEnsured.add(dir) - }) - - const walk = (directory: string): Effect.Effect, PlatformError.PlatformError> => + const ensureDirectory = (name: string) => Effect.gen(function* () { - const entries = yield* fileSystem - .readDirectory(directory) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + const dir = path.dirname(cassettePath(name)) + if (directoriesEnsured.has(dir)) return + yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie) + directoriesEnsured.add(dir) + }) + + const walk = (current: string): Effect.Effect> => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[]))) const nested = yield* Effect.forEach(entries, (entry) => { - const full = path.join(directory, entry) - return fileSystem.stat(full).pipe( + const full = path.join(current, entry) + return fs.stat(full).pipe( Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))), Effect.catch(() => Effect.succeed([] as string[])), ) @@ -64,50 +82,68 @@ export const layer = (options: { readonly directory?: string } = {}) => return nested.flat() }) - const read = Effect.fn("Cassette.read")(function* (name: string) { - return parseCassette(yield* fileSystem.readFileString(pathFor(name))) + return Service.of({ + read: (name) => + fs.readFileString(cassettePath(name)).pipe( + Effect.map((raw) => parseCassette(raw).interactions), + Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))), + ), + append: (name, interaction, metadata) => + Effect.gen(function* () { + const entry = recorded.get(name) ?? { interactions: [], findings: [] } + if (!recorded.has(name)) recorded.set(name, entry) + entry.interactions.push(interaction) + entry.findings.push(...secretFindings(interaction)) + const cassette = buildCassette(name, entry.interactions, metadata) + const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})] + if (findings.length === 0) { + yield* ensureDirectory(name) + yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie) + } + return { findings } + }), + exists: (name) => + fs.access(cassettePath(name)).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ), + list: () => + walk(directory).pipe( + Effect.map((files) => + files + .filter((file) => file.endsWith(".json")) + .map((file) => path.relative(directory, file).replace(/\\/g, "/").replace(/\.json$/, "")) + .toSorted((a, b) => a.localeCompare(b)), + ), + ), }) - - const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) { - yield* ensureDirectory(name) - yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette)) - }) - - const append = Effect.fn("Cassette.append")(function* ( - name: string, - interaction: Interaction, - metadata: CassetteMetadata | undefined, - ) { - const entry = recorded.get(name) ?? { interactions: [], findings: [] } - entry.interactions.push(interaction) - entry.findings.push(...secretFindings(interaction)) - recorded.set(name, entry) - const cassette = cassetteFor(name, entry.interactions, metadata) - const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})] - if (findings.length === 0) yield* write(name, cassette) - return { cassette, findings } - }) - - const exists = Effect.fn("Cassette.exists")(function* (name: string) { - return yield* fileSystem.access(pathFor(name)).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ) - }) - - const list = Effect.fn("Cassette.list")(function* () { - return (yield* walk(directory)) - .filter((file) => file.endsWith(".json")) - .map((file) => ({ - name: path - .relative(directory, file) - .replace(/\\/g, "/") - .replace(/\.json$/, ""), - path: file, - })) - .toSorted((a, b) => a.name.localeCompare(b.name)) - }) - - return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings }) }), ) + +export const memory = (initial: Record> = {}): Layer.Layer => + Layer.sync(Service, () => { + const stored = new Map( + Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]), + ) + const accumulatedFindings = new Map() + + return Service.of({ + read: (name) => + stored.has(name) + ? Effect.succeed(stored.get(name) ?? []) + : Effect.fail(new CassetteNotFoundError({ cassetteName: name })), + append: (name, interaction, metadata) => + Effect.sync(() => { + const existing = stored.get(name) + if (existing) existing.push(interaction) + else stored.set(name, [interaction]) + const findings = accumulatedFindings.get(name) + if (findings) findings.push(...secretFindings(interaction)) + else accumulatedFindings.set(name, [...secretFindings(interaction)]) + if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata })) + return { findings: accumulatedFindings.get(name) ?? [] } + }), + exists: (name) => Effect.sync(() => stored.has(name)), + list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()), + }) + }) diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts index 1583f327d3..e6c3ccbc15 100644 --- a/packages/http-recorder/src/effect.ts +++ b/packages/http-recorder/src/effect.ts @@ -138,7 +138,7 @@ export const recordingLayer = ( export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer => recordingLayer(name, options).pipe( - Layer.provide(CassetteService.layer({ directory: options.directory })), + Layer.provide(CassetteService.fileSystem({ directory: options.directory })), Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer), ) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts index 1ebac49e5a..4b47e4513d 100644 --- a/packages/http-recorder/src/index.ts +++ b/packages/http-recorder/src/index.ts @@ -7,9 +7,9 @@ export type { WebSocketFrame, WebSocketInteraction, } from "./schema" -export { hasCassetteSync } from "./storage" +export { CassetteNotFoundError, hasCassetteSync } from "./cassette" export { defaultMatcher, type RequestMatcher } from "./matching" -export { cassetteSecretFindings, redactHeaders, redactUrl, type SecretFinding } from "./redaction" +export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction" export { UnsafeCassetteError } from "./recorder" export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect" export { diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts index c63e3b3105..0fd28541eb 100644 --- a/packages/http-recorder/src/recorder.ts +++ b/packages/http-recorder/src/recorder.ts @@ -1,19 +1,17 @@ -import { Effect, PlatformError, Ref, Scope } from "effect" +import { Data, Effect, Ref, Scope } from "effect" import type * as CassetteService from "./cassette" +import type { CassetteNotFoundError } from "./cassette" import type { SecretFinding } from "./redaction" -import type { Cassette, CassetteMetadata, Interaction } from "./schema" +import type { CassetteMetadata, Interaction } from "./schema" -export class UnsafeCassetteError extends Error { - readonly _tag = "UnsafeCassetteError" - constructor( - readonly cassetteName: string, - readonly findings: ReadonlyArray, - ) { - super( - `Refusing to write cassette "${cassetteName}" because it contains possible secrets: ${findings - .map((finding) => `${finding.path} (${finding.reason})`) - .join(", ")}`, - ) +export class UnsafeCassetteError extends Data.TaggedError("UnsafeCassetteError")<{ + readonly cassetteName: string + readonly findings: ReadonlyArray +}> { + override get message() { + return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings + .map((finding) => `${finding.path} (${finding.reason})`) + .join(", ")}` } } @@ -35,16 +33,17 @@ export const appendOrFail = ( name: string, interaction: Interaction, metadata: CassetteMetadata | undefined, -): Effect.Effect => +): Effect.Effect => cassette.append(name, interaction, metadata).pipe( - Effect.orDie, - Effect.flatMap(({ cassette: result, findings }) => - findings.length === 0 ? Effect.succeed(result) : Effect.fail(new UnsafeCassetteError(name, findings)), + Effect.flatMap(({ findings }) => + findings.length === 0 + ? Effect.void + : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })), ), ) export interface ReplayState { - readonly load: Effect.Effect, PlatformError.PlatformError> + readonly load: Effect.Effect, CassetteNotFoundError> readonly cursor: Effect.Effect readonly advance: Effect.Effect } @@ -52,7 +51,7 @@ export interface ReplayState { export const makeReplayState = ( cassette: CassetteService.Interface, name: string, - project: (cassette: Cassette) => ReadonlyArray, + project: (interactions: ReadonlyArray) => ReadonlyArray, ): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts index 3a8b097839..59adedb4c2 100644 --- a/packages/http-recorder/src/redaction.ts +++ b/packages/http-recorder/src/redaction.ts @@ -1,5 +1,3 @@ -import type { Cassette } from "./schema" - export const REDACTED = "[REDACTED]" const DEFAULT_REDACT_HEADERS = [ @@ -113,4 +111,3 @@ export const secretFindings = (value: unknown): ReadonlyArray => .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), ]) -export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette) diff --git a/packages/http-recorder/src/schema.ts b/packages/http-recorder/src/schema.ts index 2692b525b4..113769c7b7 100644 --- a/packages/http-recorder/src/schema.ts +++ b/packages/http-recorder/src/schema.ts @@ -52,9 +52,10 @@ export const isHttpInteraction = InteractionSchema.guards.http export const isWebSocketInteraction = InteractionSchema.guards.websocket -export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction) +export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) -export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction) +export const webSocketInteractions = (interactions: ReadonlyArray) => + interactions.filter(isWebSocketInteraction) export const CassetteSchema = Schema.Struct({ version: Schema.Literal(1), diff --git a/packages/http-recorder/src/storage.ts b/packages/http-recorder/src/storage.ts deleted file mode 100644 index e8f7869f6e..0000000000 --- a/packages/http-recorder/src/storage.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Option } from "effect" -import * as fs from "node:fs" -import * as path from "node:path" -import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema" - -export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") - -export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`) - -export const cassetteFor = ( - name: string, - interactions: ReadonlyArray, - metadata: CassetteMetadata | undefined, -): Cassette => ({ - version: 1, - metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) }, - interactions, -}) - -export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` - -export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw)) - -export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => { - const file = cassettePath(name, options.directory) - if (!fs.existsSync(file)) return false - return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8"))) -} diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts index 117620cc64..f75140e8e8 100644 --- a/packages/http-recorder/test/record-replay.test.ts +++ b/packages/http-recorder/test/record-replay.test.ts @@ -7,7 +7,18 @@ import * as os from "node:os" import * as path from "node:path" import { HttpRecorder } from "../src" import { redactedErrorRequest } from "../src/effect" -import { cassetteFor, formatCassette, parseCassette } from "../src/storage" +import type { Interaction } from "../src/schema" + +const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => + Effect.runPromise( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) + }).pipe( + Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), + Effect.provide(NodeFileSystem.layer), + ), + ) const post = (url: string, body: object) => Effect.gen(function* () { @@ -34,7 +45,7 @@ const runRecorder = (effect: Effect.Effect { test("detects secret-looking values without returning the secret", () => { expect( - HttpRecorder.cassetteSecretFindings({ + HttpRecorder.secretFindings({ version: 1, interactions: [ { @@ -137,7 +148,7 @@ describe("http-recorder", () => { test("detects secret-looking values inside metadata", () => { expect( - HttpRecorder.cassetteSecretFindings({ + HttpRecorder.secretFindings({ version: 1, metadata: { token: "sk-123456789012345678901234" }, interactions: [], @@ -145,60 +156,42 @@ describe("http-recorder", () => { ).toEqual([{ path: "metadata.token", reason: "API key" }]) }) - test("formats websocket cassettes with shared metadata", () => { - const cassette = cassetteFor( - "websocket/basic", - [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], - server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], - }, - ], - { provider: "openai" }, - ) + test("replays websocket interactions seeded into the in-memory cassette adapter", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + const executor = yield* HttpRecorder.makeWebSocketExecutor({ + name: "websocket/replay", + cassette, + compareClientMessagesAsJson: true, + live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) }, + }) + const connection = yield* executor.open({ + url: "wss://example.test/realtime", + headers: Headers.fromInput({ "content-type": "application/json" }), + }) + yield* connection.sendText(JSON.stringify({ type: "response.create" })) + const messages: Array = [] + yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message)))) + yield* connection.close - expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" }) - expect(parseCassette(formatCassette(cassette))).toEqual(cassette) - }) - - test("replays websocket interactions from the shared cassette service", async () => { - await runRecorder( - Effect.gen(function* () { - const cassette = yield* HttpRecorder.Cassette.Service - yield* cassette.write( - "websocket/replay", - cassetteFor( - "websocket/replay", - [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], - server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], - }, - ], - undefined, + expect(messages).toEqual([JSON.stringify({ type: "response.completed" })]) + }).pipe( + Effect.provide( + HttpRecorder.Cassette.memory({ + "websocket/replay": [ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ], + }), ), - ) - const executor = yield* HttpRecorder.makeWebSocketExecutor({ - name: "websocket/replay", - cassette, - compareClientMessagesAsJson: true, - live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) }, - }) - const connection = yield* executor.open({ - url: "wss://example.test/realtime", - headers: Headers.fromInput({ "content-type": "application/json" }), - }) - yield* connection.sendText(JSON.stringify({ type: "response.create" })) - const messages: Array = [] - yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message)))) - yield* connection.close - - expect(messages).toEqual([JSON.stringify({ type: "response.completed" })]) - }), + ), + ), ) }) @@ -228,17 +221,14 @@ describe("http-recorder", () => { yield* connection.messages.pipe(Stream.runDrain) yield* connection.close - expect(yield* cassette.read("websocket/record")).toMatchObject({ - metadata: { name: "websocket/record", provider: "test" }, - interactions: [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], - server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], - }, - ], - }) + expect(yield* cassette.read("websocket/record")).toMatchObject([ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ]) }), ) }) @@ -303,28 +293,18 @@ describe("http-recorder", () => { test("auto mode replays when the cassette exists", async () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-")) - const cassettePath = path.join(directory, "auto-replay.json") - fs.writeFileSync( - cassettePath, - formatCassette( - cassetteFor( - "auto-replay", - [ - { - transport: "http", - request: { - method: "POST", - url: "https://example.test/echo", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ step: 1 }), - }, - response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' }, - }, - ], - undefined, - ), - ), - ) + await seedCassetteDirectory(directory, "auto-replay", [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/echo", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ step: 1 }), + }, + response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' }, + }, + ]) const result = await runWith( "auto-replay", diff --git a/packages/llm/test/recorded-test.ts b/packages/llm/test/recorded-test.ts index 6514f13dad..62e51337d9 100644 --- a/packages/llm/test/recorded-test.ts +++ b/packages/llm/test/recorded-test.ts @@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) => ...metadata, } const mode = recorderOptions?.mode ?? (recording ? "record" : "replay") - const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe( + const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe( Layer.provide(NodeFileSystem.layer), ) const requestExecutor = RequestExecutor.layer.pipe(