refactor(http-recorder): hide cassette format behind Cassette seam (#26725)

This commit is contained in:
Kit Langton
2026-05-10 12:29:55 -04:00
committed by GitHub
parent fa15dbc5ec
commit 2bd3d9a696
10 changed files with 225 additions and 231 deletions

View File

@@ -160,9 +160,9 @@ const program = Effect.gen(function* () {
## Inspecting cassettes programmatically ## Inspecting cassettes programmatically
`Cassette.Service` exposes `read`, `write`, `append`, `exists`, `list`, and `Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read`
`scan` (re-running the secret detector over an existing cassette). Useful returns the recorded interactions for a name; the file format is hidden
for CI checks: behind the seam. Useful for CI checks:
```ts ```ts
import { HttpRecorder } from "@opencode-ai/http-recorder" import { HttpRecorder } from "@opencode-ai/http-recorder"
@@ -170,13 +170,22 @@ import { Effect } from "effect"
const audit = Effect.gen(function* () { const audit = Effect.gen(function* () {
const cassettes = yield* HttpRecorder.Cassette.Service const cassettes = yield* HttpRecorder.Cassette.Service
const findings = yield* Effect.forEach(yield* cassettes.list(), (entry) => const entries = yield* cassettes.list()
cassettes.read(entry.name).pipe(Effect.map((c) => ({ entry, findings: cassettes.scan(c) }))), 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 ## Options reference
```ts ```ts

View File

@@ -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 * as path from "node:path"
import { cassetteSecretFindings, secretFindings, type SecretFinding } from "./redaction" import { secretFindings, type SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema" import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
export interface Entry { const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
readonly name: string
readonly path: string 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<SecretFinding>
} }
export interface Interface { export interface Interface {
readonly path: (name: string) => string readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
readonly append: ( readonly append: (
name: string, name: string,
interaction: Interaction, interaction: Interaction,
metadata: CassetteMetadata | undefined, metadata?: CassetteMetadata,
) => Effect.Effect< ) => Effect.Effect<AppendResult>
{
readonly cassette: Cassette
readonly findings: ReadonlyArray<SecretFinding>
},
PlatformError.PlatformError
>
readonly exists: (name: string) => Effect.Effect<boolean> readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError> readonly list: () => Effect.Effect<ReadonlyArray<string>>
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {} export class Service extends Context.Service<Service, Interface>()("@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<Interaction>,
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<Service, never, FileSystem.FileSystem> =>
Layer.effect( Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>() const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
const directoriesEnsured = new Set<string>() const directoriesEnsured = new Set<string>()
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 ensureDirectory = (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<ReadonlyArray<string>, PlatformError.PlatformError> =>
Effect.gen(function* () { Effect.gen(function* () {
const entries = yield* fileSystem const dir = path.dirname(cassettePath(name))
.readDirectory(directory) if (directoriesEnsured.has(dir)) return
.pipe(Effect.catch(() => Effect.succeed([] as string[]))) yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
directoriesEnsured.add(dir)
})
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
Effect.gen(function* () {
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
const nested = yield* Effect.forEach(entries, (entry) => { const nested = yield* Effect.forEach(entries, (entry) => {
const full = path.join(directory, entry) const full = path.join(current, entry)
return fileSystem.stat(full).pipe( return fs.stat(full).pipe(
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))), Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
Effect.catch(() => Effect.succeed([] as string[])), Effect.catch(() => Effect.succeed([] as string[])),
) )
@@ -64,50 +82,68 @@ export const layer = (options: { readonly directory?: string } = {}) =>
return nested.flat() return nested.flat()
}) })
const read = Effect.fn("Cassette.read")(function* (name: string) { return Service.of({
return parseCassette(yield* fileSystem.readFileString(pathFor(name))) 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<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
Layer.sync(Service, () => {
const stored = new Map<string, Interaction[]>(
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
)
const accumulatedFindings = new Map<string, SecretFinding[]>()
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()),
})
})

View File

@@ -138,7 +138,7 @@ export const recordingLayer = (
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> => export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
recordingLayer(name, options).pipe( recordingLayer(name, options).pipe(
Layer.provide(CassetteService.layer({ directory: options.directory })), Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer), Layer.provide(NodeFileSystem.layer),
) )

View File

@@ -7,9 +7,9 @@ export type {
WebSocketFrame, WebSocketFrame,
WebSocketInteraction, WebSocketInteraction,
} from "./schema" } from "./schema"
export { hasCassetteSync } from "./storage" export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
export { defaultMatcher, type RequestMatcher } from "./matching" 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 { UnsafeCassetteError } from "./recorder"
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect" export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
export { export {

View File

@@ -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 * as CassetteService from "./cassette"
import type { CassetteNotFoundError } from "./cassette"
import type { SecretFinding } from "./redaction" import type { SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema" import type { CassetteMetadata, Interaction } from "./schema"
export class UnsafeCassetteError extends Error { export class UnsafeCassetteError extends Data.TaggedError("UnsafeCassetteError")<{
readonly _tag = "UnsafeCassetteError" readonly cassetteName: string
constructor( readonly findings: ReadonlyArray<SecretFinding>
readonly cassetteName: string, }> {
readonly findings: ReadonlyArray<SecretFinding>, override get message() {
) { return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
super( .map((finding) => `${finding.path} (${finding.reason})`)
`Refusing to write cassette "${cassetteName}" because it contains possible secrets: ${findings .join(", ")}`
.map((finding) => `${finding.path} (${finding.reason})`)
.join(", ")}`,
)
} }
} }
@@ -35,16 +33,17 @@ export const appendOrFail = (
name: string, name: string,
interaction: Interaction, interaction: Interaction,
metadata: CassetteMetadata | undefined, metadata: CassetteMetadata | undefined,
): Effect.Effect<Cassette, UnsafeCassetteError> => ): Effect.Effect<void, UnsafeCassetteError> =>
cassette.append(name, interaction, metadata).pipe( cassette.append(name, interaction, metadata).pipe(
Effect.orDie, Effect.flatMap(({ findings }) =>
Effect.flatMap(({ cassette: result, findings }) => findings.length === 0
findings.length === 0 ? Effect.succeed(result) : Effect.fail(new UnsafeCassetteError(name, findings)), ? Effect.void
: Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
), ),
) )
export interface ReplayState<T> { export interface ReplayState<T> {
readonly load: Effect.Effect<ReadonlyArray<T>, PlatformError.PlatformError> readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
readonly cursor: Effect.Effect<number> readonly cursor: Effect.Effect<number>
readonly advance: Effect.Effect<void> readonly advance: Effect.Effect<void>
} }
@@ -52,7 +51,7 @@ export interface ReplayState<T> {
export const makeReplayState = <T>( export const makeReplayState = <T>(
cassette: CassetteService.Interface, cassette: CassetteService.Interface,
name: string, name: string,
project: (cassette: Cassette) => ReadonlyArray<T>, project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
): Effect.Effect<ReplayState<T>, never, Scope.Scope> => ): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
Effect.gen(function* () { Effect.gen(function* () {
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))

View File

@@ -1,5 +1,3 @@
import type { Cassette } from "./schema"
export const REDACTED = "[REDACTED]" export const REDACTED = "[REDACTED]"
const DEFAULT_REDACT_HEADERS = [ const DEFAULT_REDACT_HEADERS = [
@@ -113,4 +111,3 @@ export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
]) ])
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)

View File

@@ -52,9 +52,10 @@ export const isHttpInteraction = InteractionSchema.guards.http
export const isWebSocketInteraction = InteractionSchema.guards.websocket export const isWebSocketInteraction = InteractionSchema.guards.websocket
export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction) export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction) export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
interactions.filter(isWebSocketInteraction)
export const CassetteSchema = Schema.Struct({ export const CassetteSchema = Schema.Struct({
version: Schema.Literal(1), version: Schema.Literal(1),

View File

@@ -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<Interaction>,
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")))
}

View File

@@ -7,7 +7,18 @@ import * as os from "node:os"
import * as path from "node:path" import * as path from "node:path"
import { HttpRecorder } from "../src" import { HttpRecorder } from "../src"
import { redactedErrorRequest } from "../src/effect" 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<Interaction>) =>
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) => const post = (url: string, body: object) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -34,7 +45,7 @@ const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Ser
Effect.scoped( Effect.scoped(
effect.pipe( effect.pipe(
Effect.provide( Effect.provide(
HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }), HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
), ),
Effect.provide(NodeFileSystem.layer), Effect.provide(NodeFileSystem.layer),
), ),
@@ -109,7 +120,7 @@ describe("http-recorder", () => {
test("detects secret-looking values without returning the secret", () => { test("detects secret-looking values without returning the secret", () => {
expect( expect(
HttpRecorder.cassetteSecretFindings({ HttpRecorder.secretFindings({
version: 1, version: 1,
interactions: [ interactions: [
{ {
@@ -137,7 +148,7 @@ describe("http-recorder", () => {
test("detects secret-looking values inside metadata", () => { test("detects secret-looking values inside metadata", () => {
expect( expect(
HttpRecorder.cassetteSecretFindings({ HttpRecorder.secretFindings({
version: 1, version: 1,
metadata: { token: "sk-123456789012345678901234" }, metadata: { token: "sk-123456789012345678901234" },
interactions: [], interactions: [],
@@ -145,60 +156,42 @@ describe("http-recorder", () => {
).toEqual([{ path: "metadata.token", reason: "API key" }]) ).toEqual([{ path: "metadata.token", reason: "API key" }])
}) })
test("formats websocket cassettes with shared metadata", () => { test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
const cassette = cassetteFor( await Effect.runPromise(
"websocket/basic", Effect.scoped(
[ Effect.gen(function* () {
{ const cassette = yield* HttpRecorder.Cassette.Service
transport: "websocket", const executor = yield* HttpRecorder.makeWebSocketExecutor({
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, name: "websocket/replay",
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], cassette,
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], compareClientMessagesAsJson: true,
}, live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
], })
{ provider: "openai" }, 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<string | Uint8Array> = []
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(messages).toEqual([JSON.stringify({ type: "response.completed" })])
expect(parseCassette(formatCassette(cassette))).toEqual(cassette) }).pipe(
}) Effect.provide(
HttpRecorder.Cassette.memory({
test("replays websocket interactions from the shared cassette service", async () => { "websocket/replay": [
await runRecorder( {
Effect.gen(function* () { transport: "websocket",
const cassette = yield* HttpRecorder.Cassette.Service open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
yield* cassette.write( client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
"websocket/replay", server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
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,
), ),
) ),
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<string | Uint8Array> = []
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.messages.pipe(Stream.runDrain)
yield* connection.close yield* connection.close
expect(yield* cassette.read("websocket/record")).toMatchObject({ 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" } },
transport: "websocket", client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
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 () => { test("auto mode replays when the cassette exists", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-")) const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
const cassettePath = path.join(directory, "auto-replay.json") await seedCassetteDirectory(directory, "auto-replay", [
fs.writeFileSync( {
cassettePath, transport: "http",
formatCassette( request: {
cassetteFor( method: "POST",
"auto-replay", url: "https://example.test/echo",
[ headers: { "content-type": "application/json" },
{ body: JSON.stringify({ step: 1 }),
transport: "http", },
request: { response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
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,
),
),
)
const result = await runWith( const result = await runWith(
"auto-replay", "auto-replay",

View File

@@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
...metadata, ...metadata,
} }
const mode = recorderOptions?.mode ?? (recording ? "record" : "replay") 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), Layer.provide(NodeFileSystem.layer),
) )
const requestExecutor = RequestExecutor.layer.pipe( const requestExecutor = RequestExecutor.layer.pipe(