From c1a046e2d8c1110f300fb7a398ba852d765c75dd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 6 Jul 2026 18:01:24 -0400 Subject: [PATCH 1/3] feat(http-recorder): sync recorder v0.3 --- .../core/test/session-runner-recorded.test.ts | 2 +- packages/http-recorder/README.md | 33 +- packages/http-recorder/package.json | 7 +- packages/http-recorder/script/build.ts | 12 +- packages/http-recorder/script/pack.ts | 4 - .../http-recorder/script/verify-package.ts | 33 +- packages/http-recorder/src/api.ts | 46 ++ packages/http-recorder/src/cassette/model.ts | 43 ++ .../src/{cassette.ts => cassette/store.ts} | 51 +- packages/http-recorder/src/effect.ts | 25 - .../http-recorder/src/{ => http}/matching.ts | 79 +-- packages/http-recorder/src/http/model.ts | 30 + .../{internal-effect.ts => http/recorder.ts} | 95 ++- packages/http-recorder/src/index.ts | 40 +- packages/http-recorder/src/internal.ts | 25 +- packages/http-recorder/src/options.ts | 1 + packages/http-recorder/src/recorder.ts | 62 -- packages/http-recorder/src/redaction.ts | 117 ---- .../http-recorder/src/redaction/redactor.ts | 173 +++++ .../http-recorder/src/redaction/secrets.ts | 47 ++ packages/http-recorder/src/redactor.ts | 135 ---- .../http-recorder/src/replay/comparison.ts | 29 + packages/http-recorder/src/replay/state.ts | 96 +++ packages/http-recorder/src/schema.ts | 87 --- packages/http-recorder/src/socket.ts | 326 ---------- packages/http-recorder/src/types.ts | 108 ---- .../{websocket.ts => websocket/executor.ts} | 41 +- packages/http-recorder/src/websocket/model.ts | 41 ++ .../http-recorder/src/websocket/recorder.ts | 607 ++++++++++++++++++ packages/http-recorder/sst-env.d.ts | 2 +- packages/http-recorder/test/cassette.test.ts | 19 + packages/http-recorder/test/http.test.ts | 12 + .../http-recorder/test/record-replay.test.ts | 79 ++- packages/http-recorder/test/redaction.test.ts | 13 + packages/http-recorder/test/websocket.test.ts | 67 ++ packages/llm/AGENTS.md | 2 +- packages/llm/test/recorded-golden.ts | 4 +- packages/llm/test/recorded-runner.ts | 7 +- packages/llm/test/recorded-websocket.ts | 3 +- 39 files changed, 1526 insertions(+), 1077 deletions(-) create mode 100644 packages/http-recorder/src/api.ts create mode 100644 packages/http-recorder/src/cassette/model.ts rename packages/http-recorder/src/{cassette.ts => cassette/store.ts} (83%) delete mode 100644 packages/http-recorder/src/effect.ts rename packages/http-recorder/src/{ => http}/matching.ts (54%) create mode 100644 packages/http-recorder/src/http/model.ts rename packages/http-recorder/src/{internal-effect.ts => http/recorder.ts} (67%) create mode 100644 packages/http-recorder/src/options.ts delete mode 100644 packages/http-recorder/src/recorder.ts delete mode 100644 packages/http-recorder/src/redaction.ts create mode 100644 packages/http-recorder/src/redaction/redactor.ts create mode 100644 packages/http-recorder/src/redaction/secrets.ts delete mode 100644 packages/http-recorder/src/redactor.ts create mode 100644 packages/http-recorder/src/replay/comparison.ts create mode 100644 packages/http-recorder/src/replay/state.ts delete mode 100644 packages/http-recorder/src/schema.ts delete mode 100644 packages/http-recorder/src/socket.ts delete mode 100644 packages/http-recorder/src/types.ts rename packages/http-recorder/src/{websocket.ts => websocket/executor.ts} (86%) create mode 100644 packages/http-recorder/src/websocket/model.ts create mode 100644 packages/http-recorder/src/websocket/recorder.ts create mode 100644 packages/http-recorder/test/cassette.test.ts create mode 100644 packages/http-recorder/test/http.test.ts create mode 100644 packages/http-recorder/test/redaction.test.ts create mode 100644 packages/http-recorder/test/websocket.test.ts diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index aa2484d34f8f..ce63afdfcc5c 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -49,7 +49,7 @@ const cassette = directory: path.resolve(import.meta.dir, "fixtures/recordings"), mode: "record", }) - : HttpRecorder.http("session-runner/openai-chat-streams-text", { + : HttpRecorder.layerFetch("session-runner/openai-chat-streams-text", { directory: path.resolve(import.meta.dir, "fixtures/recordings"), }) const executor = RequestExecutor.layer.pipe(Layer.provide(cassette)) diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index f388e8e2539d..6f3106b1f795 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -9,13 +9,13 @@ Use it for provider integrations, retries, polling, multi-step flows, and any te ## Install ```sh -bun add effect@4.0.0-beta.74 -bun add -d @opencode-ai/http-recorder@beta @effect/vitest vitest +bun add effect@4.0.0-beta.83 +bun add -d @opencode-ai/http-recorder @effect/vitest@4.0.0-beta.83 vitest@^4 ``` The package supports Node.js 22+ and Bun. It is not intended for browsers, workers, or Deno. -Effect `4.0.0-beta.74` has a known declaration error (`SchemaErrorTypeId` is missing). Until that upstream declaration is fixed, TypeScript consumers need: +Effect `4.0.0-beta.83` currently contains unresolved symbols in its published declarations. Until those upstream declarations are fixed, TypeScript consumers need: ```json { @@ -51,7 +51,7 @@ describe("getUser", () => { assert.strictEqual(user.id, 1) assert.strictEqual(user.name, "Leanne Graham") - }).pipe(Effect.provide(HttpRecorder.http("users/get-one"))), + }).pipe(Effect.provide(HttpRecorder.layerFetch("users/get-one"))), ) }) ``` @@ -81,11 +81,13 @@ Application code does not need to know whether a response is live or replayed. ## API ```ts -HttpRecorder.http(name, options?) -HttpRecorder.socket(name, options?) +HttpRecorder.layer(name, options?) +HttpRecorder.layerFetch(name, options?) +HttpRecorder.layerSocket(name, options?) +HttpRecorder.layerWebSocketConstructor(name, options?) ``` -That is the complete public API. `http` provides a fetch-backed recorded `HttpClient`. `socket` decorates a standard Effect `Socket.Socket` supplied beneath it. +That is the complete public API. `layer` decorates a provided `HttpClient`; `layerFetch` supplies Effect's fetch client. `layerSocket` decorates a provided `Socket.Socket`, while `layerWebSocketConstructor` records dynamic WebSocket URLs and protocols. ## WebSockets @@ -112,7 +114,7 @@ const echo = Effect.gen(function* () { ) }) -const recordedSocket = HttpRecorder.socket("echo/hello").pipe( +const recordedSocket = HttpRecorder.layerSocket("echo/hello").pipe( Layer.provide( NodeSocket.layerWebSocket("wss://ws.postman-echo.com/raw", { closeCodeIsError: (code) => code !== 1000, @@ -143,7 +145,7 @@ There is intentionally no public overwrite mode. Deletion makes the set of recor Secure defaults remove most headers and redact common credentials in headers, URLs, and JSON bodies. Extend those defaults at layer construction: ```ts -HttpRecorder.http("anthropic/messages", { +HttpRecorder.layerFetch("anthropic/messages", { redact: { headers: ["x-project-token"], allowRequestHeaders: ["anthropic-version"], @@ -171,16 +173,16 @@ Redaction is defense in depth, not a substitute for review. Inspect cassette dif ## Matching And Ordering -A cassette contains an ordered sequence of interactions. The first runtime request is checked against the first recorded request, the second against the second, and so on. +A runtime request atomically claims the first unused recorded interaction that matches it. Distinct requests may replay in any order or concurrently. -This strict ordering correctly models repeated identical requests whose responses change, including retries, polling, and cache tests. JSON object keys are canonicalized before matching. +Repeated identical requests consume their matching responses in cassette order, which models retries, polling, and cache tests deterministically. A mismatch consumes nothing, and JSON object keys are canonicalized before matching. Concurrent requests are recorded in request-start order even when their responses complete out of order. Supply a custom equivalence rule when a request contains intentionally volatile data: ```ts -HttpRecorder.http("events/create", { +HttpRecorder.layerFetch("events/create", { match: (incoming, recorded) => incoming.method === recorded.method && new URL(incoming.url).pathname === new URL(recorded.url).pathname, }) @@ -191,10 +193,12 @@ HttpRecorder.http("events/create", { ```ts interface RecorderOptions { readonly directory?: string - readonly metadata?: Record + readonly metadata?: Readonly> readonly redact?: RedactOptions readonly match?: RequestMatcher } + +type SocketRecorderOptions = Omit ``` `directory` defaults to `/test/fixtures/recordings`. @@ -207,7 +211,8 @@ Cassettes are readable JSON files intended to be committed with your tests. HTTP - Responses are buffered while recording and replaying, so this beta is not suitable for tests that assert streaming timing, cancellation, or backpressure. - WebSocket replay preserves frame chronology and content, not real network timing or backpressure. -- WebSocket V1 cassettes do not reproduce terminal close codes, close reasons, or transport failures. Failed and interrupted live runs are not recorded. +- Constructor-level WebSocket cassettes reproduce terminal close codes and reasons, but not selected subprotocols, handshake headers, transport timing, or transport failures. Lower-level `layerSocket` cassettes contain frames only. +- Failed and interrupted live WebSocket connections are not recorded. - WebSocket transcripts are retained in memory until the connection finishes; avoid using this beta for unbounded sessions. - The package currently requires the exact Effect beta listed above. - Cassette format version `1` has no migration tooling yet. diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 0d4f1175ad0b..9437671a2a25 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "version": "1.17.14", "name": "@opencode-ai/http-recorder", - "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", + "description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes", "type": "module", "license": "MIT", "repository": { @@ -36,6 +36,7 @@ ".": "./src/index.ts", "./internal": "./src/internal.ts" }, + "sideEffects": false, "files": [ "dist", "README.md", @@ -47,14 +48,14 @@ "@types/bun": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "@effect/platform-node": "catalog:", "effect": "catalog:", "typescript": "catalog:" }, "dependencies": { - "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83" }, "peerDependencies": { - "effect": "4.0.0-beta.83" + "effect": "catalog:" } } diff --git a/packages/http-recorder/script/build.ts b/packages/http-recorder/script/build.ts index f7d839236ebb..636c65017796 100644 --- a/packages/http-recorder/script/build.ts +++ b/packages/http-recorder/script/build.ts @@ -1,12 +1,12 @@ #!/usr/bin/env bun import { $ } from "bun" -import { readdir, rm } from "node:fs/promises" +import { rm } from "node:fs/promises" await rm("dist", { recursive: true, force: true }) await $`bunx tsc --emitDeclarationOnly` const build = await Bun.build({ - entrypoints: ["src/index.ts"], + entrypoints: ["src/index.ts", "src/internal.ts"], outdir: "dist", target: "node", format: "esm", @@ -14,7 +14,7 @@ const build = await Bun.build({ }) if (!build.success) throw new AggregateError(build.logs, "Failed to build @opencode-ai/http-recorder") -const publicFiles = new Set(["index.js", "index.d.ts", "effect.d.ts", "socket.d.ts", "types.d.ts"]) -await Promise.all( - (await readdir("dist")).filter((file) => !publicFiles.has(file)).map((file) => rm(`dist/${file}`, { force: true })), -) +for (const file of ["dist/index.d.ts", "dist/api.d.ts", "dist/internal.d.ts"]) { + if ((await Bun.file(file).text()).includes(["import", "("].join(""))) + throw new Error(`${file} contains dynamic import syntax`) +} diff --git a/packages/http-recorder/script/pack.ts b/packages/http-recorder/script/pack.ts index 4dad7a441673..11cc4668b883 100644 --- a/packages/http-recorder/script/pack.ts +++ b/packages/http-recorder/script/pack.ts @@ -15,10 +15,6 @@ export const pack = async () => { } for (const [key, value] of Object.entries(pkg.exports)) { - if (key === "./internal") { - delete pkg.exports[key] - continue - } if (typeof value !== "string") continue const file = value.replace("./src/", "./dist/").replace(/\.ts$/, "") pkg.exports[key] = { import: `${file}.js`, types: `${file}.d.ts` } diff --git a/packages/http-recorder/script/verify-package.ts b/packages/http-recorder/script/verify-package.ts index 272394c9446c..32806fbc7a39 100644 --- a/packages/http-recorder/script/verify-package.ts +++ b/packages/http-recorder/script/verify-package.ts @@ -20,16 +20,27 @@ export const verifyPackage = async (archive: string) => { await writeFile( path.join(directory, "consumer.ts"), `import { HttpRecorder } from "@opencode-ai/http-recorder" +import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { NodeSocket } from "@effect/platform-node" import { Layer } from "effect" import { HttpClient } from "effect/unstable/http" import { Socket } from "effect/unstable/socket" -const options: HttpRecorder.RecorderOptions = { redact: { jsonFields: ["access_token"] } } +const options: HttpRecorder.RecorderOptions = { match: () => true, redact: { jsonFields: ["access_token"] } } +const socketOptions: HttpRecorder.SocketRecorderOptions = { redact: { jsonFields: ["access_token"] } } +HttpRecorder.layer("consumer", options) satisfies Layer.Layer +HttpRecorder.layerFetch("consumer", options) satisfies Layer.Layer HttpRecorder.http("consumer", options) satisfies Layer.Layer -HttpRecorder.socket("consumer/socket", options).pipe( +HttpRecorder.layerSocket("consumer/socket", socketOptions).pipe( Layer.provide(NodeSocket.layerWebSocket("wss://example.test")), ) satisfies Layer.Layer +HttpRecorder.layerWebSocketConstructor("consumer/websocket", socketOptions).pipe( + Layer.provide(NodeSocket.layerWebSocketConstructor), +) satisfies Layer.Layer +HttpRecorderInternal.hasCassetteSync satisfies (name: string, options?: { readonly directory?: string }) => boolean +HttpRecorderInternal.Cassette.memory() +// @ts-expect-error HTTP request matching does not apply to WebSocket frames. +HttpRecorder.layerSocket("consumer/socket", { match: () => true }) `, ) await writeFile( @@ -49,13 +60,27 @@ HttpRecorder.socket("consumer/socket", options).pipe( }), ) - await run(["npm", "install", archive, "typescript@5.8.2"], directory) + await run( + [ + "npm", + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--package-lock=false", + archive, + "typescript@5.8.2", + "effect@4.0.0-beta.83", + "@effect/platform-node@4.0.0-beta.83", + ], + directory, + ) await run( [ "node", "--input-type=module", "-e", - 'import("@opencode-ai/http-recorder").then((module) => { const root = Object.keys(module).sort(); const namespace = Object.keys(module.HttpRecorder).sort(); if (JSON.stringify(root) !== JSON.stringify(["HttpRecorder"])) throw new Error(`Unexpected root exports: ${root}`); if (JSON.stringify(namespace) !== JSON.stringify(["http", "socket"])) throw new Error(`Unexpected namespace exports: ${namespace}`) })', + 'Promise.all([import("@opencode-ai/http-recorder"), import("@opencode-ai/http-recorder/internal")]).then(([module, internal]) => { const root = Object.keys(module).sort(); const namespace = Object.keys(module.HttpRecorder).sort(); if (JSON.stringify(root) !== JSON.stringify(["HttpRecorder"])) throw new Error(`Unexpected root exports: ${root}`); if (JSON.stringify(namespace) !== JSON.stringify(["http", "layer", "layerFetch", "layerSocket", "layerWebSocketConstructor"])) throw new Error(`Unexpected namespace exports: ${namespace}`); if (!("Cassette" in internal) || !("makeWebSocketExecutor" in internal)) throw new Error("Missing internal compatibility exports") })', ], directory, ) diff --git a/packages/http-recorder/src/api.ts b/packages/http-recorder/src/api.ts new file mode 100644 index 000000000000..47d652029b8d --- /dev/null +++ b/packages/http-recorder/src/api.ts @@ -0,0 +1,46 @@ +/** JSON-compatible cassette metadata value. */ +export type JsonValue = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: JsonValue } + +/** Additional JSON metadata stored with a cassette. */ +export type CassetteMetadata = Readonly> + +/** The normalized HTTP request representation used for matching. */ +export interface RequestSnapshot { + readonly method: string + readonly url: string + readonly headers: Record + readonly body: string +} + +/** Returns whether an incoming HTTP request matches a recorded request. */ +export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean + +/** Additive redaction and header-preservation policy. */ +export interface RedactOptions { + readonly headers?: ReadonlyArray + readonly allowRequestHeaders?: ReadonlyArray + readonly allowResponseHeaders?: ReadonlyArray + readonly queryParameters?: ReadonlyArray + readonly jsonFields?: ReadonlyArray + readonly url?: (url: string) => string + readonly body?: (body: string) => string +} + +/** Options shared by HTTP recorder layers. */ +export interface RecorderOptions { + readonly directory?: string + readonly metadata?: CassetteMetadata + readonly redact?: RedactOptions + readonly match?: RequestMatcher +} + +/** Recorder configuration for Effect socket and WebSocket layers. */ +export type SocketRecorderOptions = Omit + +export * as Api from "./api.js" diff --git a/packages/http-recorder/src/cassette/model.ts b/packages/http-recorder/src/cassette/model.ts new file mode 100644 index 000000000000..8667505da8a5 --- /dev/null +++ b/packages/http-recorder/src/cassette/model.ts @@ -0,0 +1,43 @@ +import { Schema } from "effect" +import type { CassetteMetadata, JsonValue } from "../api.js" +import { HttpInteractionSchema } from "../http/model.js" +import { WebSocketInteractionSchema } from "../websocket/model.js" + +export type { CassetteMetadata, JsonValue } from "../api.js" + +const JsonValueSchema = Schema.suspend( + (): Schema.Codec => + Schema.Union([ + Schema.Null, + Schema.Boolean, + Schema.Number, + Schema.String, + Schema.Array(JsonValueSchema), + Schema.Record(Schema.String, JsonValueSchema), + ]), +) + +export const CassetteMetadataSchema = Schema.Record(Schema.String, JsonValueSchema) + +export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe( + Schema.toTaggedUnion("transport"), +) +export type Interaction = Schema.Schema.Type + +export const isHttpInteraction = InteractionSchema.guards.http +export const isWebSocketInteraction = InteractionSchema.guards.websocket +export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) +export const webSocketInteractions = (interactions: ReadonlyArray) => + interactions.filter(isWebSocketInteraction) + +export const CassetteSchema = Schema.Struct({ + version: Schema.Literal(1), + metadata: Schema.optional(CassetteMetadataSchema), + interactions: Schema.Array(InteractionSchema), +}) +export type Cassette = Schema.Schema.Type + +export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema) +export const encodeCassette = Schema.encodeSync(CassetteSchema) + +export * as CassetteModel from "./model.js" diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette/store.ts similarity index 83% rename from packages/http-recorder/src/cassette.ts rename to packages/http-recorder/src/cassette/store.ts index 5138bb12dffe..1feb2113e667 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette/store.ts @@ -1,8 +1,8 @@ import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect" -import * as fs from "node:fs" -import * as path from "node:path" -import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js" -import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js" +import { existsSync } from "node:fs" +import path from "node:path" +import { secretFindings, SecretFindingSchema, type SecretFinding } from "../redaction/secrets.js" +import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./model.js" const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") @@ -14,6 +14,15 @@ export class CassetteNotFoundError extends Schema.TaggedErrorClass()("InvalidCassetteError", { + cassetteName: Schema.String, + description: Schema.String, +}) { + override get message() { + return `Cassette "${this.cassetteName}" is invalid: ${this.description}` + } +} + export class UnsafeCassetteError extends Schema.TaggedErrorClass()("UnsafeCassetteError", { cassetteName: Schema.String, findings: Schema.Array(SecretFindingSchema), @@ -26,7 +35,9 @@ export class UnsafeCassetteError extends Schema.TaggedErrorClass Effect.Effect, CassetteNotFoundError> + readonly read: ( + name: string, + ) => Effect.Effect, CassetteNotFoundError | InvalidCassetteError> readonly append: ( name: string, interaction: Interaction, @@ -50,7 +61,7 @@ const cassettePath = (directory: string, name: string) => { } export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => - fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name)) + existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name)) const buildCassette = ( name: string, @@ -58,14 +69,16 @@ const buildCassette = ( metadata: CassetteMetadata | undefined, ): Cassette => ({ version: 1, - metadata: { name, recordedAt: new Date().toISOString(), ...metadata }, + metadata: { ...metadata, name, recordedAt: new Date().toISOString() }, interactions, }) - const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` - const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema)) - +const invalidCassette = (name: string, error: unknown) => + new InvalidCassetteError({ + cassetteName: name, + description: error instanceof Error ? error.message : String(error), + }) const failIfUnsafe = (name: string, findings: ReadonlyArray) => findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })) @@ -79,9 +92,7 @@ export const fileSystem = ( const directory = options.directory ?? DEFAULT_RECORDINGS_DIR const recorded = new Map() const appendLock = yield* Semaphore.make(1) - const pathFor = (name: string) => cassettePath(directory, name) - const walk = (current: string): Effect.Effect> => Effect.gen(function* () { const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[]))) @@ -98,8 +109,17 @@ export const fileSystem = ( return Service.of({ read: (name) => fs.readFileString(pathFor(name)).pipe( - Effect.map((raw) => parseCassette(raw).interactions), - Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))), + Effect.mapError((error) => + error.reason._tag === "NotFound" + ? new CassetteNotFoundError({ cassetteName: name }) + : invalidCassette(name, error), + ), + Effect.flatMap((raw) => + Effect.try({ + try: () => parseCassette(raw).interactions, + catch: (error) => invalidCassette(name, error), + }), + ), ), append: (name, interaction, metadata) => appendLock.withPermit( @@ -151,7 +171,6 @@ export const memory = (initial: Record> = {}) ) const accumulatedFindings = new Map() const appendLock = Semaphore.makeUnsafe(1) - return Service.of({ read: (name) => stored.has(name) @@ -162,7 +181,7 @@ export const memory = (initial: Record> = {}) Effect.suspend(() => { const interactions = [...(stored.get(name) ?? []), interaction] const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)] - const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings + const allFindings = metadata ? [...findings, ...secretFindings({ ...metadata, name })] : findings return failIfUnsafe(name, allFindings).pipe( Effect.tap(() => Effect.sync(() => { diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts deleted file mode 100644 index 583dee8ca122..000000000000 --- a/packages/http-recorder/src/effect.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import * as Layer from "effect/Layer" -import { FetchHttpClient } from "effect/unstable/http" -import type * as HttpClient from "effect/unstable/http/HttpClient" -import * as CassetteService from "./cassette.js" -import { recordingLayer } from "./internal-effect.js" -import { make } from "./redactor.js" -import type { RecorderOptions } from "./types.js" - -/** - * Provides a fetch-backed `HttpClient` with cassette recording and replay. - * - * Locally, a missing cassette is recorded from the real service. Existing - * cassettes are replayed, and `CI=true` makes a missing cassette fail. - */ -export const http = (name: string, options: RecorderOptions = {}): Layer.Layer => - recordingLayer(name, { - metadata: options.metadata, - redactor: make(options.redact), - match: options.match, - }).pipe( - Layer.provide(CassetteService.fileSystem({ directory: options.directory })), - Layer.provide(FetchHttpClient.layer), - Layer.provide(NodeFileSystem.layer), - ) diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/http/matching.ts similarity index 54% rename from packages/http-recorder/src/matching.ts rename to packages/http-recorder/src/http/matching.ts index 731aa8b57ac2..fcd446eddad0 100644 --- a/packages/http-recorder/src/matching.ts +++ b/packages/http-recorder/src/http/matching.ts @@ -1,64 +1,31 @@ -import { Option, Schema } from "effect" -import { REDACTED, secretFindings } from "./redaction.js" -import type { HttpInteraction, RequestMatcher, RequestSnapshot } from "./types.js" +import { HashSet, Option } from "effect" +import type { RequestMatcher, RequestSnapshot } from "../api.js" +import { canonicalizeJson, decodeJson, isJsonRecord, jsonBody, safeText } from "../replay/comparison.js" +import type { HttpInteraction } from "./model.js" -const JsonValue = Schema.fromJsonString(Schema.Unknown) -export const decodeJson = Schema.decodeUnknownOption(JsonValue) - -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === "object" && !Array.isArray(value) - -export const canonicalizeJson = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(canonicalizeJson) - if (isRecord(value)) { - return Object.fromEntries( - Object.keys(value) - .toSorted() - .map((key) => [key, canonicalizeJson(value[key])]), - ) - } - return value -} - -export type { RequestMatcher } from "./types.js" +export type { RequestMatcher } from "../api.js" export const canonicalSnapshot = (snapshot: RequestSnapshot): string => JSON.stringify({ method: snapshot.method, url: snapshot.url, headers: canonicalizeJson(snapshot.headers), - body: Option.match(decodeJson(snapshot.body), { - onNone: () => snapshot.body, - onSome: canonicalizeJson, - }), + body: Option.match(decodeJson(snapshot.body), { onNone: () => snapshot.body, onSome: canonicalizeJson }), }) - export const defaultMatcher: RequestMatcher = (incoming, recorded) => canonicalSnapshot(incoming) === canonicalSnapshot(recorded) -export const safeText = (value: unknown) => { - if (value === undefined) return "undefined" - if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) - const text = JSON.stringify(value) - if (!text) return typeof value - return text.length > 300 ? `${text.slice(0, 300)}...` : text -} - -const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) - const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray => { if (Object.is(expected, received)) return [] - if (isRecord(expected) && isRecord(received)) { + if (isJsonRecord(expected) && isJsonRecord(received)) return [...new Set([...Object.keys(expected), ...Object.keys(received)])] .toSorted() .flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit)) .slice(0, limit) - } - if (Array.isArray(expected) && Array.isArray(received)) { + if (Array.isArray(expected) && Array.isArray(received)) return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index) .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit)) .slice(0, limit) - } return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`] } @@ -72,12 +39,9 @@ const headerDiffs = (expected: Record, received: Record => { const lines: string[] = [] - if (expected.method !== received.method) { + if (expected.method !== received.method) lines.push("method:", ` expected ${expected.method}, received ${received.method}`) - } - if (expected.url !== received.url) { - lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) - } + if (expected.url !== received.url) lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) const headers = headerDiffs(expected.headers, received.headers) if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) const expectedBody = jsonBody(expected.body) @@ -92,15 +56,22 @@ export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot return lines } -export const selectSequential = ( +export const selectFirstMatching = ( interactions: ReadonlyArray, incoming: RequestSnapshot, match: RequestMatcher, - index: number, -): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { - const interaction = interactions[index] - if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` } - if (!match(incoming, interaction.request)) - return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") } - return { interaction, detail: "" } + used: HashSet.HashSet, +): { readonly _tag: "Matched"; readonly index: number } | { readonly _tag: "Unmatched"; readonly detail: string } => { + let firstUnused: HttpInteraction | undefined + for (let index = 0; index < interactions.length; index++) { + if (HashSet.has(used, index)) continue + const interaction = interactions[index] + firstUnused ??= interaction + if (match(incoming, interaction.request)) return { _tag: "Matched", index } + } + if (firstUnused === undefined) + return { _tag: "Unmatched", detail: `all ${interactions.length} recorded interactions have already been consumed` } + return { _tag: "Unmatched", detail: requestDiff(firstUnused.request, incoming).join("\n") } } + +export * as HttpMatching from "./matching.js" diff --git a/packages/http-recorder/src/http/model.ts b/packages/http-recorder/src/http/model.ts new file mode 100644 index 000000000000..861d502f882a --- /dev/null +++ b/packages/http-recorder/src/http/model.ts @@ -0,0 +1,30 @@ +import { Schema } from "effect" +import type { RequestSnapshot } from "../api.js" + +export const RequestSnapshotSchema = Schema.Struct({ + method: Schema.String, + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, +}) + +export type { RequestSnapshot } from "../api.js" + +export const ResponseSnapshotSchema = Schema.Struct({ + status: Schema.Number, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, + bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])), +}) + +export interface ResponseSnapshot extends Schema.Schema.Type {} + +export const HttpInteractionSchema = Schema.Struct({ + transport: Schema.tag("http"), + request: RequestSnapshotSchema, + response: ResponseSnapshotSchema, +}) + +export interface HttpInteraction extends Schema.Schema.Type {} + +export * as HttpModel from "./model.js" diff --git a/packages/http-recorder/src/internal-effect.ts b/packages/http-recorder/src/http/recorder.ts similarity index 67% rename from packages/http-recorder/src/internal-effect.ts rename to packages/http-recorder/src/http/recorder.ts index 6b886311a995..568b101743ad 100644 --- a/packages/http-recorder/src/internal-effect.ts +++ b/packages/http-recorder/src/http/recorder.ts @@ -1,27 +1,22 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { Deferred, Effect, Layer, Option, Ref } from "effect" +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Deferred, Effect, Layer, Ref } from "effect" import { FetchHttpClient, - Headers, - HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, - UrlParams, } from "effect/unstable/http" -import * as CassetteService from "./cassette.js" -import { defaultMatcher, selectSequential } from "./matching.js" -import { makeReplayState, resolveAutoMode } from "./recorder.js" -import { make, type Redactor } from "./redactor.js" -import { redactUrl } from "./redaction.js" -import { httpInteractions } from "./schema.js" -import type { CassetteMetadata, HttpInteraction, RequestMatcher, ResponseSnapshot } from "./types.js" +import { fileSystem, Service } from "../cassette/store.js" +import type { RecorderOptions } from "../options.js" +import { make, redactUrl, type Redactor } from "../redaction/redactor.js" +import { makeReplayPoolState, resolveAutoMode } from "../replay/state.js" +import { httpInteractions, type CassetteMetadata } from "../cassette/model.js" +import { defaultMatcher, selectFirstMatching, type RequestMatcher } from "./matching.js" +import type { HttpInteraction, ResponseSnapshot } from "./model.js" export { defaultMatcher } - export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough" - export interface RecordReplayOptions { readonly mode?: RecordReplayMode readonly directory?: string @@ -40,7 +35,6 @@ const TEXT_CONTENT_TYPES = new Set([ "application/yaml", "image/svg+xml", ]) - const isTextContentType = (contentType: string | undefined) => { const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase() if (!mediaType) return false @@ -51,7 +45,6 @@ const isTextContentType = (contentType: string | undefined) => { TEXT_CONTENT_TYPES.has(mediaType) ) } - const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) => response.arrayBuffer.pipe( Effect.map((bytes) => @@ -60,10 +53,8 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co : { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const }, ), ) - const decodeResponseBody = (snapshot: ResponseSnapshot) => snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body - const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snapshot: ResponseSnapshot) => HttpClientResponse.fromWeb( request, @@ -75,35 +66,28 @@ const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snap ), ) -export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => - HttpClientRequest.makeWith( - request.method, - redactUrl(request.url), - UrlParams.empty, - Option.none(), - Headers.empty, - HttpBody.empty, - ) - -const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) => +export const redactedErrorRequest = ( + request: HttpClientRequest.HttpClientRequest, + redactedUrl = redactUrl(request.url), +) => HttpClientRequest.make(request.method)(redactedUrl) +const transportError = (request: HttpClientRequest.HttpClientRequest, description: string, redactedUrl?: string) => new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }), + reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request, redactedUrl), description }), }) export const recordingLayer = ( name: string, options: Omit = {}, -): Layer.Layer => +): Layer.Layer => Layer.effect( HttpClient.HttpClient, Effect.gen(function* () { const upstream = yield* HttpClient.HttpClient - const cassetteService = yield* CassetteService.Service + const cassette = yield* Service const redactor = options.redactor ?? make() const match = options.match ?? defaultMatcher const requested = options.mode ?? "auto" - const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested - + const mode = requested === "auto" ? yield* resolveAutoMode(cassette, name) : requested const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) @@ -114,9 +98,7 @@ export const recordingLayer = ( body: yield* Effect.promise(() => web.text()), }) }) - if (mode === "passthrough") return upstream - if (mode === "record") { const initial = yield* Deferred.make() yield* Deferred.succeed(initial, undefined) @@ -127,6 +109,7 @@ export const recordingLayer = ( const previous = yield* Ref.modify(tail, (current) => [current, completed]) return yield* Effect.gen(function* () { const incoming = yield* snapshotRequest(request) + const requestError = (description: string) => transportError(request, description, incoming.url) const response = yield* upstream.execute(request) const captured = yield* captureResponseBody(response, response.headers["content-type"]) const responseSnapshot: ResponseSnapshot = { @@ -140,39 +123,32 @@ export const recordingLayer = ( response: redactor.response(responseSnapshot), } yield* Deferred.await(previous) - yield* cassetteService + yield* cassette .append(name, interaction, options.metadata) - .pipe( - Effect.catchTag("UnsafeCassetteError", (error) => - Effect.fail(transportError(request, error.message)), - ), - ) + .pipe(Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(requestError(error.message)))) return responseFromSnapshot(request, responseSnapshot) }).pipe(Effect.ensuring(Deferred.succeed(completed, undefined))) }), ) } - - const replay = yield* makeReplayState(cassetteService, name, httpInteractions) + const replay = yield* makeReplayPoolState(cassette, name, httpInteractions) return HttpClient.make((request) => Effect.gen(function* () { const incoming = yield* snapshotRequest(request) + const requestError = (description: string) => transportError(request, description, incoming.url) const claimed = yield* replay - .claim((interaction, index, interactions) => { - const result = selectSequential(interactions, incoming, match, index) - if (result.interaction) return Effect.void + .claim((interactions, used) => { + const result = selectFirstMatching(interactions, incoming, match, used) + if (result._tag === "Matched") return Effect.succeed(result.index) return Effect.fail( - transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), + requestError(`Fixture "${name}" does not match the current request: ${result.detail}.`), ) }) .pipe( Effect.mapError((error) => error._tag === "CassetteNotFoundError" - ? transportError( - request, - `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`, - ) - : error, + ? requestError(`Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`) + : requestError(error.message), ), ) return responseFromSnapshot(request, claimed.interaction.response) @@ -183,7 +159,18 @@ export const recordingLayer = ( export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer => recordingLayer(name, options).pipe( - Layer.provide(CassetteService.fileSystem({ directory: options.directory })), + Layer.provide(fileSystem({ directory: options.directory })), Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer), ) + +export const layer = ( + name: string, + options: RecorderOptions = {}, +): Layer.Layer => + recordingLayer(name, { metadata: options.metadata, redactor: make(options.redact), match: options.match }).pipe( + Layer.provide(fileSystem({ directory: options.directory })), + Layer.provide(NodeFileSystem.layer), + ) +export const layerFetch = (name: string, options: RecorderOptions = {}): Layer.Layer => + layer(name, options).pipe(Layer.provide(FetchHttpClient.layer)) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts index eac4b75b4bba..5f8c0fb240e2 100644 --- a/packages/http-recorder/src/index.ts +++ b/packages/http-recorder/src/index.ts @@ -1,18 +1,42 @@ -import { http } from "./effect.js" -import { socket } from "./socket.js" +import { Layer } from "effect" +import { HttpClient } from "effect/unstable/http" +import { Socket } from "effect/unstable/socket" +import { Api } from "./api.js" +import { layer, layerFetch } from "./http/recorder.js" +import { layerSocket, layerWebSocketConstructor } from "./websocket/recorder.js" /** HTTP and WebSocket cassette recording. */ -export const HttpRecorder = { http, socket } as const +export const HttpRecorder: { + readonly layer: ( + name: string, + options?: Api.RecorderOptions, + ) => Layer.Layer + readonly layerFetch: (name: string, options?: Api.RecorderOptions) => Layer.Layer + /** @deprecated Use `layerFetch`. Retained for the V1 OpenCode test harness. */ + readonly http: (name: string, options?: Api.RecorderOptions) => Layer.Layer + readonly layerSocket: ( + name: string, + options?: Api.SocketRecorderOptions, + ) => Layer.Layer + readonly layerWebSocketConstructor: ( + name: string, + options?: Api.SocketRecorderOptions, + ) => Layer.Layer +} = { layer, layerFetch, http: layerFetch, layerSocket, layerWebSocketConstructor } export namespace HttpRecorder { + /** JSON-compatible cassette metadata value. */ + export type JsonValue = Api.JsonValue /** Additional JSON metadata stored with a cassette. */ - export type CassetteMetadata = import("./types.js").CassetteMetadata + export type CassetteMetadata = Api.CassetteMetadata /** Recorder configuration. */ - export type RecorderOptions = import("./types.js").RecorderOptions + export type RecorderOptions = Api.RecorderOptions /** Additive redaction and header-preservation policy. */ - export type RedactOptions = import("./types.js").RedactOptions + export type RedactOptions = Api.RedactOptions /** Returns whether an incoming HTTP request matches a recorded request. */ - export type RequestMatcher = import("./types.js").RequestMatcher + export type RequestMatcher = Api.RequestMatcher /** The normalized HTTP request representation used for matching. */ - export type RequestSnapshot = import("./types.js").RequestSnapshot + export type RequestSnapshot = Api.RequestSnapshot + /** Recorder configuration for Effect socket and WebSocket layers. */ + export type SocketRecorderOptions = Api.SocketRecorderOptions } diff --git a/packages/http-recorder/src/internal.ts b/packages/http-recorder/src/internal.ts index 7faecf0db062..92ebf81386d5 100644 --- a/packages/http-recorder/src/internal.ts +++ b/packages/http-recorder/src/internal.ts @@ -1,15 +1,26 @@ -export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette.js" -export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./internal-effect.js" -export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction.js" -export { socketLayer } from "./socket.js" +import { layerSocketWithMode, type WebSocketRecorderOptions } from "./websocket/recorder.js" +import { Layer } from "effect" +import { Socket } from "effect/unstable/socket" + +export { CassetteNotFoundError, hasCassetteSync, InvalidCassetteError, UnsafeCassetteError } from "./cassette/store.js" +export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./http/recorder.js" +export { redactHeaders, redactUrl } from "./redaction/redactor.js" +export { secretFindings, type SecretFinding } from "./redaction/secrets.js" +export { layerSocketWithMode } from "./websocket/recorder.js" +export const socketLayer = ( + name: string, + connection: { readonly url: string; readonly headers?: Record }, + options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" }, +): Layer.Layer => + layerSocketWithMode(name, { ...options, open: { url: connection.url, headers: connection.headers ?? {} } }) export { makeWebSocketExecutor, type WebSocketConnection, type WebSocketExecutor, type WebSocketRecordReplayOptions, type WebSocketRequest, -} from "./websocket.js" -export * as Cassette from "./cassette.js" -export * as Redactor from "./redactor.js" +} from "./websocket/executor.js" +export * as Cassette from "./cassette/store.js" +export * as Redactor from "./redaction/redactor.js" export * as HttpRecorderInternal from "./internal.js" diff --git a/packages/http-recorder/src/options.ts b/packages/http-recorder/src/options.ts new file mode 100644 index 000000000000..df62d6a2470a --- /dev/null +++ b/packages/http-recorder/src/options.ts @@ -0,0 +1 @@ +export type { RecorderOptions, RedactOptions, SocketRecorderOptions } from "./api.js" diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts deleted file mode 100644 index c58afde4633c..000000000000 --- a/packages/http-recorder/src/recorder.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Effect, Scope, SynchronizedRef } from "effect" -import type * as CassetteService from "./cassette.js" -import type { CassetteNotFoundError } from "./cassette.js" -import type { Interaction } from "./schema.js" - -const isCI = () => { - const value = process.env.CI - return value !== undefined && value !== "" && value !== "false" && value !== "0" -} - -export const resolveAutoMode = ( - cassette: CassetteService.Interface, - name: string, -): Effect.Effect<"record" | "replay" | "passthrough"> => - Effect.gen(function* () { - if (isCI()) return "replay" - return (yield* cassette.exists(name)) ? "replay" : "record" - }) - -export interface ReplayState { - readonly claim: ( - validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray) => Effect.Effect, - ) => Effect.Effect<{ readonly interaction: T; readonly index: number }, CassetteNotFoundError | E> -} - -export const makeReplayState = ( - cassette: CassetteService.Interface, - name: string, - project: (interactions: ReadonlyArray) => ReadonlyArray, -): Effect.Effect, never, Scope.Scope> => - Effect.gen(function* () { - const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) - const position = yield* SynchronizedRef.make(0) - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const used = yield* SynchronizedRef.get(position) - if (used === 0) return yield* Effect.void - const interactions = yield* load.pipe(Effect.orDie) - if (used < interactions.length) - return yield* Effect.die( - new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`), - ) - return yield* Effect.void - }), - ) - - return { - claim: (validate) => - Effect.flatMap(load, (interactions) => - SynchronizedRef.modifyEffect(position, (index) => - Effect.gen(function* () { - const interaction = interactions[index] - yield* validate(interaction, index, interactions) - if (interaction === undefined) - return yield* Effect.die("Replay validation accepted a missing interaction") - return [{ interaction, index }, index + 1] as const - }), - ), - ), - } - }) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts deleted file mode 100644 index b84996adecb9..000000000000 --- a/packages/http-recorder/src/redaction.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Schema } from "effect" - -export const REDACTED = "[REDACTED]" - -const DEFAULT_REDACT_HEADERS = [ - "authorization", - "cookie", - "proxy-authorization", - "set-cookie", - "x-api-key", - "x-amz-security-token", - "x-goog-api-key", -] - -const DEFAULT_REDACT_QUERY = [ - "access_token", - "api-key", - "api_key", - "apikey", - "code", - "key", - "signature", - "sig", - "token", - "x-amz-credential", - "x-amz-security-token", - "x-amz-signature", -] - -const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [ - { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i }, - { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ }, - { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, - { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ }, - { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, - { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ }, - { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, -] - -const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i -const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"]) - -const envSecrets = () => - Object.entries(process.env).flatMap(([name, value]) => { - if (!value) return [] - if (!ENV_SECRET_NAMES.test(name)) return [] - if (value.length < 12) return [] - if (SAFE_ENV_VALUES.has(value.toLowerCase())) return [] - return [{ name, value }] - }) - -const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key) - -const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => { - if (typeof value === "string") return [{ path: base, value }] - if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`)) - if (value && typeof value === "object") { - return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key))) - } - return [] -} - -const redactionSet = (values: ReadonlyArray | undefined, defaults: ReadonlyArray) => - new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase())) - -export type UrlRedactor = (url: string) => string - -export const redactUrl = ( - raw: string, - query: ReadonlyArray = DEFAULT_REDACT_QUERY, - urlRedactor?: UrlRedactor, -) => { - if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw - const url = new URL(raw) - if (url.username) url.username = REDACTED - if (url.password) url.password = REDACTED - const redacted = redactionSet(query, DEFAULT_REDACT_QUERY) - for (const key of url.searchParams.keys()) { - if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED) - } - return urlRedactor?.(url.toString()) ?? url.toString() -} - -export const redactHeaders = ( - headers: Record, - allow: ReadonlyArray, - redact: ReadonlyArray = DEFAULT_REDACT_HEADERS, -) => { - const allowed = new Set(allow.map((name) => name.toLowerCase())) - const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS) - return Object.fromEntries( - Object.entries(headers) - .map(([name, value]) => [name.toLowerCase(), value] as const) - .filter(([name]) => allowed.has(name)) - .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const) - .toSorted(([a], [b]) => a.localeCompare(b)), - ) -} - -export const SecretFindingSchema = Schema.Struct({ - path: Schema.String, - reason: Schema.String, -}) -export type SecretFinding = Schema.Schema.Type - -export const secretFindings = (value: unknown): ReadonlyArray => { - const environment = envSecrets() - return stringEntries(value).flatMap((entry) => [ - ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({ - path: entry.path, - reason: item.label, - })), - ...environment - .filter((item) => entry.value.includes(item.value)) - .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), - ]) -} diff --git a/packages/http-recorder/src/redaction/redactor.ts b/packages/http-recorder/src/redaction/redactor.ts new file mode 100644 index 000000000000..d921f55d73f4 --- /dev/null +++ b/packages/http-recorder/src/redaction/redactor.ts @@ -0,0 +1,173 @@ +import { Option, Schema } from "effect" +import type { RequestSnapshot, ResponseSnapshot } from "../http/model.js" +import type { RedactOptions } from "../options.js" + +export type { RedactOptions } from "../options.js" +export const REDACTED = "[REDACTED]" + +const DEFAULT_REDACT_HEADERS = [ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", + "x-api-key", + "x-amz-security-token", + "x-goog-api-key", +] +const DEFAULT_REDACT_QUERY = [ + "access_token", + "api-key", + "api_key", + "apikey", + "code", + "key", + "signature", + "sig", + "token", + "x-amz-credential", + "x-amz-security-token", + "x-amz-signature", +] +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const redactionSet = (values: ReadonlyArray | undefined, defaults: ReadonlyArray) => + new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase())) + +export const redactUrl = ( + raw: string, + query: ReadonlyArray = DEFAULT_REDACT_QUERY, + transform?: (url: string) => string, +) => { + if (!URL.canParse(raw)) return transform?.(raw) ?? raw + const url = new URL(raw) + if (url.username) url.username = REDACTED + if (url.password) url.password = REDACTED + const redacted = redactionSet(query, DEFAULT_REDACT_QUERY) + for (const key of url.searchParams.keys()) if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED) + return transform?.(url.toString()) ?? url.toString() +} + +export const redactHeaders = ( + headers: Record, + allow: ReadonlyArray, + redact: ReadonlyArray = DEFAULT_REDACT_HEADERS, +) => { + const allowed = new Set(allow.map((name) => name.toLowerCase())) + const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS) + return Object.fromEntries( + Object.entries(headers) + .map(([name, value]) => [name.toLowerCase(), value] as const) + .filter(([name]) => allowed.has(name)) + .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const) + .toSorted(([a], [b]) => a.localeCompare(b)), + ) +} + +const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] +const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] +const identity = (value: T) => value + +export interface Redactor { + readonly request: (snapshot: RequestSnapshot) => RequestSnapshot + readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot +} + +export const compose = (...redactors: ReadonlyArray>): Redactor => { + const requests = redactors + .map((redactor) => redactor.request) + .filter((fn): fn is Redactor["request"] => fn !== undefined) + const responses = redactors + .map((redactor) => redactor.response) + .filter((fn): fn is Redactor["response"] => fn !== undefined) + return { + request: requests.length === 0 ? identity : (snapshot) => requests.reduce((value, fn) => fn(value), snapshot), + response: responses.length === 0 ? identity : (snapshot) => responses.reduce((value, fn) => fn(value), snapshot), + } +} + +interface HeaderOptions { + readonly allow?: ReadonlyArray + readonly redact?: ReadonlyArray +} +const requestHeaders = (options: HeaderOptions = {}): Partial => ({ + request: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact), + }), +}) +const responseHeaders = (options: HeaderOptions = {}): Partial => ({ + response: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact), + }), +}) + +interface UrlOptions { + readonly query?: ReadonlyArray + readonly transform?: (url: string) => string +} +const url = (options: UrlOptions = {}): Partial => ({ + request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }), +}) + +const DEFAULT_REDACT_JSON_FIELDS = [ + "access_token", + "api_key", + "apikey", + "client_secret", + "password", + "refresh_token", + "secret", + "token", +] +const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase() +interface RedactedJson { + readonly value: unknown + readonly changed: boolean +} +const redactJsonFields = (value: unknown, fields: ReadonlySet): RedactedJson => { + if (Array.isArray(value)) { + const items = value.map((item) => redactJsonFields(item, fields)) + return { value: items.map((item) => item.value), changed: items.some((item) => item.changed) } + } + if (!value || typeof value !== "object") return { value, changed: false } + let changed = false + const entries = Object.entries(value).map(([key, child]) => { + if (fields.has(normalizeField(key))) { + if (child !== REDACTED) changed = true + return [key, REDACTED] as const + } + const redacted = redactJsonFields(child, fields) + if (redacted.changed) changed = true + return [key, redacted.value] as const + }) + return { value: Object.fromEntries(entries), changed } +} +const redactBody = (value: string, fields: ReadonlySet, transform: ((body: string) => string) | undefined) => { + const redacted = Option.match(decodeJson(value), { + onNone: () => value, + onSome: (parsed) => { + const result = redactJsonFields(parsed, fields) + return result.changed ? JSON.stringify(result.value) : value + }, + }) + return transform?.(redacted) ?? redacted +} + +export const make = (options: RedactOptions = {}): Redactor => { + const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField)) + return compose( + requestHeaders({ + allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])], + redact: options.headers, + }), + responseHeaders({ + allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])], + redact: options.headers, + }), + url({ query: options.queryParameters, transform: options.url }), + { + request: (snapshot) => ({ ...snapshot, body: redactBody(snapshot.body, fields, options.body) }), + response: (snapshot) => ({ ...snapshot, body: redactBody(snapshot.body, fields, options.body) }), + }, + ) +} diff --git a/packages/http-recorder/src/redaction/secrets.ts b/packages/http-recorder/src/redaction/secrets.ts new file mode 100644 index 000000000000..b3ffd3c3a632 --- /dev/null +++ b/packages/http-recorder/src/redaction/secrets.ts @@ -0,0 +1,47 @@ +import { Schema } from "effect" + +const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [ + { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i }, + { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ }, + { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, + { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ }, + { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, + { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ }, + { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, +] + +const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i +const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"]) + +const envSecrets = () => + Object.entries(process.env).flatMap(([name, value]) => { + if (!value || !ENV_SECRET_NAMES.test(name) || value.length < 12 || SAFE_ENV_VALUES.has(value.toLowerCase())) + return [] + return [{ name, value }] + }) + +const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key) + +const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => { + if (typeof value === "string") return [{ path: base, value }] + if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`)) + if (value && typeof value === "object") + return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key))) + return [] +} + +export const SecretFindingSchema = Schema.Struct({ path: Schema.String, reason: Schema.String }) +export type SecretFinding = Schema.Schema.Type + +export const secretFindings = (value: unknown): ReadonlyArray => { + const environment = envSecrets() + return stringEntries(value).flatMap((entry) => [ + ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({ + path: entry.path, + reason: item.label, + })), + ...environment + .filter((item) => entry.value.includes(item.value)) + .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), + ]) +} diff --git a/packages/http-recorder/src/redactor.ts b/packages/http-recorder/src/redactor.ts deleted file mode 100644 index 582c647702ab..000000000000 --- a/packages/http-recorder/src/redactor.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Option } from "effect" -import { decodeJson } from "./matching.js" -import { REDACTED, redactHeaders, redactUrl } from "./redaction.js" -import type { RedactOptions, RequestSnapshot, ResponseSnapshot } from "./types.js" - -export type { RedactOptions } from "./types.js" - -export const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] -export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] - -const identity = (value: T) => value - -export interface Redactor { - readonly request: (snapshot: RequestSnapshot) => RequestSnapshot - readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot -} - -export const compose = (...redactors: ReadonlyArray>): Redactor => { - const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined) - const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined) - return { - request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot), - response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot), - } -} - -export interface HeaderOptions { - readonly allow?: ReadonlyArray - readonly redact?: ReadonlyArray -} - -export const requestHeaders = (options: HeaderOptions = {}): Partial => ({ - request: (snapshot) => ({ - ...snapshot, - headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact), - }), -}) - -export const responseHeaders = (options: HeaderOptions = {}): Partial => ({ - response: (snapshot) => ({ - ...snapshot, - headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact), - }), -}) - -export interface UrlOptions { - readonly query?: ReadonlyArray - readonly transform?: (url: string) => string -} - -export const url = (options: UrlOptions = {}): Partial => ({ - request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }), -}) - -export const body = (transform: (parsed: unknown) => unknown): Partial => ({ - request: (snapshot) => ({ - ...snapshot, - body: Option.match(decodeJson(snapshot.body), { - onNone: () => snapshot.body, - onSome: (parsed) => JSON.stringify(transform(parsed)), - }), - }), -}) - -export interface DefaultRedactorOverrides { - readonly requestHeaders?: HeaderOptions - readonly responseHeaders?: HeaderOptions - readonly url?: UrlOptions - readonly body?: (parsed: unknown) => unknown -} - -const DEFAULT_REDACT_JSON_FIELDS = [ - "access_token", - "api_key", - "apikey", - "client_secret", - "password", - "refresh_token", - "secret", - "token", -] - -const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase() - -const redactJsonFields = (value: unknown, fields: ReadonlySet): unknown => { - if (Array.isArray(value)) return value.map((item) => redactJsonFields(item, fields)) - if (!value || typeof value !== "object") return value - return Object.fromEntries( - Object.entries(value).map(([key, child]) => [ - key, - fields.has(normalizeField(key)) ? REDACTED : redactJsonFields(child, fields), - ]), - ) -} - -const redactBody = (value: string, fields: ReadonlySet, transform: ((body: string) => string) | undefined) => { - const redacted = Option.match(decodeJson(value), { - onNone: () => value, - onSome: (parsed) => JSON.stringify(redactJsonFields(parsed, fields)), - }) - return transform?.(redacted) ?? redacted -} - -export const make = (options: RedactOptions = {}): Redactor => { - const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField)) - return compose( - requestHeaders({ - allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])], - redact: options.headers, - }), - responseHeaders({ - allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])], - redact: options.headers, - }), - url({ query: options.queryParameters, transform: options.url }), - { - request: (snapshot) => ({ - ...snapshot, - body: redactBody(snapshot.body, fields, options.body), - }), - response: (snapshot) => ({ - ...snapshot, - body: redactBody(snapshot.body, fields, options.body), - }), - }, - ) -} - -export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor => - compose( - requestHeaders(overrides.requestHeaders), - responseHeaders(overrides.responseHeaders), - url(overrides.url), - ...(overrides.body ? [body(overrides.body)] : []), - ) diff --git a/packages/http-recorder/src/replay/comparison.ts b/packages/http-recorder/src/replay/comparison.ts new file mode 100644 index 000000000000..106dbd33a471 --- /dev/null +++ b/packages/http-recorder/src/replay/comparison.ts @@ -0,0 +1,29 @@ +import { Option, Schema } from "effect" +import { REDACTED } from "../redaction/redactor.js" +import { secretFindings } from "../redaction/secrets.js" + +export const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value) + +export const canonicalizeJson = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonicalizeJson) + if (isRecord(value)) + return Object.fromEntries( + Object.keys(value) + .toSorted() + .map((key) => [key, canonicalizeJson(value[key])]), + ) + return value +} + +export const safeText = (value: unknown) => { + if (value === undefined) return "undefined" + if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) + const text = JSON.stringify(value) + if (!text) return typeof value + return text.length > 300 ? `${text.slice(0, 300)}...` : text +} + +export const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) +export const isJsonRecord = isRecord diff --git a/packages/http-recorder/src/replay/state.ts b/packages/http-recorder/src/replay/state.ts new file mode 100644 index 000000000000..79f6edfb3aec --- /dev/null +++ b/packages/http-recorder/src/replay/state.ts @@ -0,0 +1,96 @@ +import { Effect, Exit, HashSet, Ref, Scope, SynchronizedRef } from "effect" +import type { Interaction } from "../cassette/model.js" +import type { CassetteNotFoundError, Interface, InvalidCassetteError } from "../cassette/store.js" + +const isCI = () => { + const value = process.env.CI + return value !== undefined && value !== "" && value !== "false" && value !== "0" +} + +export const resolveAutoMode = ( + cassette: Interface, + name: string, +): Effect.Effect<"record" | "replay" | "passthrough"> => + Effect.gen(function* () { + if (isCI()) return "replay" + return (yield* cassette.exists(name)) ? "replay" : "record" + }) + +export interface ReplayState { + readonly claim: ( + validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray) => Effect.Effect, + ) => Effect.Effect< + { readonly interaction: T; readonly index: number }, + CassetteNotFoundError | InvalidCassetteError | E + > +} +export interface ReplayPoolState { + readonly claim: ( + select: (interactions: ReadonlyArray, used: HashSet.HashSet) => Effect.Effect, + ) => Effect.Effect< + { readonly interaction: T; readonly index: number }, + CassetteNotFoundError | InvalidCassetteError | E + > +} + +export const makeReplayPoolState = ( + cassette: Interface, + name: string, + project: (interactions: ReadonlyArray) => ReadonlyArray, +): Effect.Effect, never, Scope.Scope> => + Effect.gen(function* () { + const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) + const claimed = yield* SynchronizedRef.make(HashSet.empty()) + const attempted = yield* Ref.make(false) + yield* Effect.addFinalizer((exit) => + Exit.isFailure(exit) + ? Effect.void + : Effect.gen(function* () { + const used = yield* SynchronizedRef.get(claimed) + if (HashSet.isEmpty(used) && (yield* Ref.get(attempted))) return yield* Effect.void + const interactions = yield* load.pipe( + Effect.catchTag("CassetteNotFoundError", () => Effect.succeed([] as ReadonlyArray)), + Effect.orDie, + ) + if (HashSet.size(used) < interactions.length) + return yield* Effect.die( + new Error( + `Unused recorded interactions in ${name}: used ${HashSet.size(used)} of ${interactions.length}`, + ), + ) + return yield* Effect.void + }), + ) + return { + claim: (select) => + Ref.set(attempted, true).pipe( + Effect.andThen(load), + Effect.flatMap((interactions) => + SynchronizedRef.modifyEffect(claimed, (used) => + Effect.gen(function* () { + const index = yield* select(interactions, used) + const interaction = interactions[index] + if (interaction === undefined || HashSet.has(used, index)) + return yield* Effect.die("Replay selected an unavailable interaction") + return [{ interaction, index }, HashSet.add(used, index)] as const + }), + ), + ), + ), + } + }) + +export const makeReplayState = ( + cassette: Interface, + name: string, + project: (interactions: ReadonlyArray) => ReadonlyArray, +): Effect.Effect, never, Scope.Scope> => + makeReplayPoolState(cassette, name, project).pipe( + Effect.map((pool) => ({ + claim: (validate) => + pool.claim((interactions, used) => { + const index = HashSet.size(used) + return validate(interactions[index], index, interactions).pipe(Effect.as(index)) + }), + })), + ) diff --git a/packages/http-recorder/src/schema.ts b/packages/http-recorder/src/schema.ts deleted file mode 100644 index a9e673780437..000000000000 --- a/packages/http-recorder/src/schema.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Schema } from "effect" -import type { - CassetteMetadata, - HttpInteraction, - RequestSnapshot, - ResponseSnapshot, - WebSocketEvent, - WebSocketInteraction, -} from "./types.js" - -export type { - CassetteMetadata, - HttpInteraction, - RequestSnapshot, - ResponseSnapshot, - WebSocketEvent, - WebSocketInteraction, -} from "./types.js" - -export const RequestSnapshotSchema = Schema.Struct({ - method: Schema.String, - url: Schema.String, - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.String, -}) - -export const ResponseSnapshotSchema = Schema.Struct({ - status: Schema.Number, - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.String, - bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])), -}) - -export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown) - -export const HttpInteractionSchema = Schema.Struct({ - transport: Schema.tag("http"), - request: RequestSnapshotSchema, - response: ResponseSnapshotSchema, -}) - -export const WebSocketEventSchema = Schema.Union([ - Schema.Struct({ - direction: Schema.Literals(["client", "server"]), - kind: Schema.tag("text"), - body: Schema.String, - }), - Schema.Struct({ - direction: Schema.Literals(["client", "server"]), - kind: Schema.tag("binary"), - body: Schema.String, - bodyEncoding: Schema.Literal("base64"), - }), -]) - -export const WebSocketInteractionSchema = Schema.Struct({ - transport: Schema.tag("websocket"), - open: Schema.Struct({ - url: Schema.String, - headers: Schema.Record(Schema.String, Schema.String), - }), - events: Schema.Array(WebSocketEventSchema), -}) - -export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe( - Schema.toTaggedUnion("transport"), -) -export type Interaction = Schema.Schema.Type - -export const isHttpInteraction = InteractionSchema.guards.http - -export const isWebSocketInteraction = InteractionSchema.guards.websocket - -export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) - -export const webSocketInteractions = (interactions: ReadonlyArray) => - interactions.filter(isWebSocketInteraction) - -export const CassetteSchema = Schema.Struct({ - version: Schema.Literal(1), - metadata: Schema.optional(CassetteMetadataSchema), - interactions: Schema.Array(InteractionSchema), -}) -export type Cassette = Schema.Schema.Type - -export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema) -export const encodeCassette = Schema.encodeSync(CassetteSchema) diff --git a/packages/http-recorder/src/socket.ts b/packages/http-recorder/src/socket.ts deleted file mode 100644 index c7486db4aa29..000000000000 --- a/packages/http-recorder/src/socket.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { Deferred, Effect, Exit, FiberSet, Layer, Ref, Scope, Semaphore } from "effect" -import { Socket } from "effect/unstable/socket" -import * as CassetteService from "./cassette.js" -import { canonicalizeJson, decodeJson, safeText } from "./matching.js" -import { makeReplayState, resolveAutoMode } from "./recorder.js" -import { make, type Redactor } from "./redactor.js" -import { webSocketInteractions } from "./schema.js" -import type { - RecorderOptions, - WebSocketEvent, - WebSocketInteraction, - WebSocketRecorderOptions, - WebSocketRequest, -} from "./types.js" - -interface ActiveReplay { - readonly interaction: WebSocketInteraction - readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred }> - readonly writeLock: Semaphore.Semaphore - readonly closed: Ref.Ref -} - -interface ActiveRecording { - readonly events: Array - readonly eventLock: Semaphore.Semaphore - readonly accepting: Ref.Ref - opened: boolean - valid: boolean -} - -type Frame = string | Uint8Array - -const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent => - typeof message === "string" - ? { direction, kind: "text", body: message } - : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } - -const decodeEvent = (event: WebSocketEvent): Frame => - event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) - -const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => { - if (event.kind === "binary") return event - const body = - event.direction === "client" - ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body - : redactor.response({ status: 101, headers: {}, body: event.body }).body - return { ...event, body } -} - -const comparable = (event: WebSocketEvent, asJson: boolean) => { - if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event)) - const decoded = decodeJson(event.body) - return JSON.stringify( - canonicalizeJson({ - ...event, - body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value), - }), - ) -} - -const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => - Effect.sync(() => { - if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return - throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) - }) - -const runHandler = (handler: (value: A) => Effect.Effect | void, value: A) => - Effect.suspend(() => { - const result = handler(value) - return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void - }) - -const runReplay = ( - state: ActiveReplay, - handler: (value: A) => Effect.Effect | void, - decode: (event: WebSocketEvent) => A, - onOpen: Effect.Effect | undefined, -) => - Effect.scoped( - Effect.gen(function* () { - const handlers = yield* FiberSet.make() - const run = yield* FiberSet.runtime(handlers)() - if (onOpen) yield* onOpen - - const drive = Effect.gen(function* () { - while (true) { - const current = yield* Ref.get(state.progress) - const event = state.interaction.events[current.position] - if (!event) return - if (yield* Ref.get(state.closed)) - return yield* Effect.die( - new Error( - `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, - ), - ) - if (event.direction === "server") { - yield* Ref.set(state.progress, { - position: current.position + 1, - changed: yield* Deferred.make(), - }) - run(runHandler(handler, decode(event))) - continue - } - yield* Deferred.await(current.changed) - } - }) - - yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers))) - yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers))) - }), - ) - -const openSnapshot = (request: WebSocketRequest, redactor: Redactor) => { - const snapshot = redactor.request({ method: "GET", url: request.url, headers: request.headers ?? {}, body: "" }) - return { url: snapshot.url, headers: snapshot.headers } -} - -const makeRecordingSocket = ( - upstream: Socket.Socket, - cassette: CassetteService.Interface, - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions, - redactor: Redactor, -) => - Effect.gen(function* () { - const active = yield* Ref.make(undefined) - const writeLock = yield* Semaphore.make(1) - - return Socket.make({ - runRaw: (handler, runOptions) => - Effect.gen(function* () { - const state: ActiveRecording = { - events: [], - eventLock: yield* Semaphore.make(1), - accepting: yield* Ref.make(true), - opened: false, - valid: true, - } - const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) - if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported") - yield* upstream - .runRaw( - (message) => { - if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing") - state.events.push(redactEvent(encodeEvent("server", message), redactor)) - return handler(message) - }, - { - ...runOptions, - onOpen: Effect.gen(function* () { - state.opened = true - if (runOptions?.onOpen) yield* runOptions.onOpen - }), - }, - ) - .pipe( - Effect.onExit((exit) => - writeLock.withPermit( - state.eventLock.withPermit( - Effect.gen(function* () { - yield* Ref.set(state.accepting, false) - yield* Ref.set(active, undefined) - if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return - yield* cassette - .append( - name, - { - transport: "websocket", - open: openSnapshot(request, redactor), - events: [...state.events], - }, - options.metadata, - ) - .pipe(Effect.orDie) - }), - ), - ), - ), - ) - }), - writer: upstream.writer.pipe( - Effect.map( - (write) => (message) => - writeLock.withPermit( - Effect.gen(function* () { - if (Socket.isCloseEvent(message)) return yield* write(message) - const state = yield* Ref.get(active) - if (!state || !(yield* Ref.get(state.accepting))) - return yield* Effect.die("WebSocket writer used without an active socket run") - const event = redactEvent(encodeEvent("client", message), redactor) - yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event))) - return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false)))) - }), - ), - ), - ), - }) - }) - -const makeReplaySocket = ( - cassette: CassetteService.Interface, - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions, - redactor: Redactor, -): Effect.Effect => - Effect.gen(function* () { - const replay = yield* makeReplayState(cassette, name, webSocketInteractions) - const active = yield* Ref.make(undefined) - - return Socket.make({ - runRaw: (handler, runOptions) => - Effect.gen(function* () { - const claimed = yield* replay - .claim((interaction, index) => - Effect.sync(() => { - const incoming = openSnapshot(request, redactor) - if ( - interaction && - JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open)) - ) - return - throw new Error( - `WebSocket open ${index + 1}: expected ${safeText(interaction?.open)}, received ${safeText(incoming)}`, - ) - }), - ) - .pipe(Effect.orDie) - const progress = yield* Ref.make({ position: 0, changed: yield* Deferred.make() }) - const writeLock = yield* Semaphore.make(1) - const state = { - interaction: claimed.interaction, - progress, - writeLock, - closed: yield* Ref.make(false), - } - const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) - if (occupied) return yield* Effect.die("Concurrent runs of a replayed WebSocket are not supported") - yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe( - Effect.ensuring(Ref.set(active, undefined)), - ) - }), - writer: Effect.succeed((message) => { - return Ref.get(active).pipe( - Effect.flatMap((state) => - state - ? state.writeLock.withPermit( - Effect.gen(function* () { - const current = yield* Ref.get(state.progress) - if (Socket.isCloseEvent(message)) { - yield* Ref.set(state.closed, true) - yield* Deferred.succeed(current.changed, undefined) - if (current.position === state.interaction.events.length) return - return yield* Effect.die( - new Error( - `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, - ), - ) - } - const actual = redactEvent(encodeEvent("client", message), redactor) - yield* assertEvent( - actual, - state.interaction.events[current.position], - current.position, - options.compareClientMessagesAsJson === true, - ) - yield* Ref.set(state.progress, { - position: current.position + 1, - changed: yield* Deferred.make(), - }) - yield* Deferred.succeed(current.changed, undefined) - }), - ) - : Effect.die("WebSocket writer used without an active socket run"), - ), - ) - }), - }) - }) - -const recordingLayer = ( - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions, - forcedMode?: "record" | "replay", -): Layer.Layer => - Layer.effect( - Socket.Socket, - Effect.gen(function* () { - const upstream = yield* Socket.Socket - const cassette = yield* CassetteService.Service - const redactor = make(options.redact) - if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record") - return yield* makeRecordingSocket(upstream, cassette, name, request, options, redactor) - return yield* makeReplaySocket(cassette, name, request, options, redactor) - }), - ) - -/** - * Wraps a provided `Socket.Socket` with cassette recording and replay. - * - * Supply the ordinary URL-bound Effect socket layer beneath this decorator. - * The cassette name identifies the connection; recorder configuration does not - * duplicate the transport URL. - */ -export const socket = (name: string, options: RecorderOptions = {}): Layer.Layer => - provideCassette(recordingLayer(name, { url: "" }, { ...options, compareClientMessagesAsJson: true }), options) - -/** @internal */ -export const socketLayer = ( - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" }, -): Layer.Layer => - provideCassette(recordingLayer(name, request, options, options.mode), options) - -const provideCassette = ( - layer: Layer.Layer, - options: WebSocketRecorderOptions, -) => - layer.pipe( - Layer.provide(CassetteService.fileSystem({ directory: options.directory })), - Layer.provide(NodeFileSystem.layer), - ) diff --git a/packages/http-recorder/src/types.ts b/packages/http-recorder/src/types.ts deleted file mode 100644 index 1ee011420585..000000000000 --- a/packages/http-recorder/src/types.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** Additional JSON metadata stored with a cassette. */ -export type CassetteMetadata = Record - -/** The normalized HTTP request representation used for matching. */ -export interface RequestSnapshot { - /** HTTP method. */ - readonly method: string - /** Fully qualified URL after redaction. */ - readonly url: string - /** Allowed and redacted request headers. */ - readonly headers: Record - /** Request body after redaction. */ - readonly body: string -} - -/** @internal */ -export interface ResponseSnapshot { - /** HTTP status code. */ - readonly status: number - /** Allowed and redacted response headers. */ - readonly headers: Record - /** Text body or base64-encoded binary body. */ - readonly body: string - /** Encoding used by `body`; omitted for ordinary text. */ - readonly bodyEncoding?: "text" | "base64" -} - -/** @internal */ -export interface HttpInteraction { - readonly transport: "http" - readonly request: RequestSnapshot - readonly response: ResponseSnapshot -} - -/** @internal */ -export type WebSocketEvent = - | { readonly direction: "client" | "server"; readonly kind: "text"; readonly body: string } - | { - readonly direction: "client" | "server" - readonly kind: "binary" - readonly body: string - readonly bodyEncoding: "base64" - } - -/** @internal */ -export interface WebSocketInteraction { - readonly transport: "websocket" - readonly open: { - readonly url: string - readonly headers: Record - } - readonly events: ReadonlyArray -} - -/** Returns whether an incoming HTTP request matches a recorded request. */ -export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean - -/** Additive redaction and header-preservation policy. */ -export interface RedactOptions { - /** Additional sensitive headers to retain as `[REDACTED]`. */ - readonly headers?: ReadonlyArray - /** Additional non-sensitive request headers to preserve for matching. */ - readonly allowRequestHeaders?: ReadonlyArray - /** Additional non-sensitive response headers to preserve for replay. */ - readonly allowResponseHeaders?: ReadonlyArray - /** Additional sensitive URL query parameter names. */ - readonly queryParameters?: ReadonlyArray - /** Additional JSON field names to redact recursively. */ - readonly jsonFields?: ReadonlyArray - /** Stabilizes a URL after built-in redaction. */ - readonly url?: (url: string) => string - /** Stabilizes a request, response, or text-frame body after built-in redaction. */ - readonly body?: (body: string) => string -} - -/** Options shared by HTTP recorder layers. */ -export interface RecorderOptions { - /** Cassette directory. Defaults to `/test/fixtures/recordings`. */ - readonly directory?: string - /** Additional metadata stored in the cassette. */ - readonly metadata?: CassetteMetadata - /** Additive redaction and header-preservation policy. */ - readonly redact?: RedactOptions - /** Custom HTTP request equivalence. */ - readonly match?: RequestMatcher -} - -/** @internal */ -export interface WebSocketRequest { - /** WebSocket URL. */ - readonly url: string - /** Headers used for redacted matching; the recorder does not send them. */ - readonly headers?: Record -} - -/** @internal */ -export interface WebSocketRecorderOptions { - /** Cassette directory. Defaults to `/test/fixtures/recordings`. */ - readonly directory?: string - /** Additional metadata stored in the cassette. */ - readonly metadata?: CassetteMetadata - /** Additive handshake and text-frame redaction policy. */ - readonly redact?: RedactOptions - /** Compare text client frames as canonical JSON instead of exact strings. */ - readonly compareClientMessagesAsJson?: boolean - /** WebSocket subprotocols used by `layerWebSocket`. */ - readonly protocols?: string | Array -} diff --git a/packages/http-recorder/src/websocket.ts b/packages/http-recorder/src/websocket/executor.ts similarity index 86% rename from packages/http-recorder/src/websocket.ts rename to packages/http-recorder/src/websocket/executor.ts index 869cd3bf90e0..8a72ddf19dd2 100644 --- a/packages/http-recorder/src/websocket.ts +++ b/packages/http-recorder/src/websocket/executor.ts @@ -1,32 +1,30 @@ import { Effect, Option, Ref, Scope, Semaphore, Stream, SynchronizedRef } from "effect" import type { Headers } from "effect/unstable/http" -import * as CassetteService from "./cassette.js" -import { canonicalizeJson, decodeJson, safeText } from "./matching.js" -import { makeReplayState, resolveAutoMode } from "./recorder.js" -import type { RecordReplayMode } from "./internal-effect.js" -import { make, type Redactor } from "./redactor.js" -import { webSocketInteractions, type CassetteMetadata, type WebSocketEvent } from "./schema.js" +import type { Interface } from "../cassette/store.js" +import { webSocketInteractions, type CassetteMetadata } from "../cassette/model.js" +import type { RecordReplayMode } from "../http/recorder.js" +import { make, type Redactor } from "../redaction/redactor.js" +import { canonicalizeJson, decodeJson, safeText } from "../replay/comparison.js" +import { makeReplayState, resolveAutoMode } from "../replay/state.js" +import type { WebSocketEvent } from "./model.js" export interface WebSocketRequest { readonly url: string readonly headers: Headers.Headers } - export interface WebSocketConnection { readonly sendText: (message: string) => Effect.Effect readonly messages: Stream.Stream readonly close: Effect.Effect } - export interface WebSocketExecutor { readonly open: (request: WebSocketRequest) => Effect.Effect, E> } - export interface WebSocketRecordReplayOptions { readonly name: string readonly mode?: RecordReplayMode readonly metadata?: CassetteMetadata - readonly cassette: CassetteService.Interface + readonly cassette: Interface readonly live: WebSocketExecutor readonly redactor?: Redactor readonly compareClientMessagesAsJson?: boolean @@ -38,18 +36,10 @@ const headersRecord = (headers: Headers.Headers): Record => (entry): entry is [string, string] => typeof entry[1] === "string", ), ) - -const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({ - direction, - kind: "text", - body, -}) - +const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({ direction, kind: "text", body }) const decodeEvent = (event: WebSocketEvent) => event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) - const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson }) - const assertClientEvent = (actual: string, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => Effect.sync(() => { const matches = @@ -65,7 +55,8 @@ export const makeWebSocketExecutor = ( options: WebSocketRecordReplayOptions, ): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { - const mode = options.mode ?? (yield* resolveAutoMode(options.cassette, options.name)) + const requested = options.mode ?? "auto" + const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested const redactor = options.redactor ?? make() const openSnapshot = (request: WebSocketRequest) => { const snapshot = redactor.request({ @@ -84,9 +75,7 @@ export const makeWebSocketExecutor = ( : redactor.response({ status: 101, headers: {}, body: event.body }).body return { ...event, body } } - if (mode === "passthrough") return options.live - if (mode === "record") { return { open: (request) => @@ -134,16 +123,18 @@ export const makeWebSocketExecutor = ( }), } } - const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions) return { open: (request) => Effect.gen(function* () { + const incoming = openSnapshot(request) const claimed = yield* replay .claim((interaction, index) => Effect.sync(() => { - const incoming = canonicalizeJson(openSnapshot(request)) - if (interaction && JSON.stringify(incoming) === JSON.stringify(canonicalizeJson(interaction.open))) + if ( + interaction?.open && + JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open)) + ) return throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`) }), diff --git a/packages/http-recorder/src/websocket/model.ts b/packages/http-recorder/src/websocket/model.ts new file mode 100644 index 000000000000..c6ffdde2ed75 --- /dev/null +++ b/packages/http-recorder/src/websocket/model.ts @@ -0,0 +1,41 @@ +import { Schema } from "effect" + +export const WebSocketEventSchema = Schema.Union([ + Schema.Struct({ + direction: Schema.Literals(["client", "server"]), + kind: Schema.tag("text"), + body: Schema.String, + }), + Schema.Struct({ + direction: Schema.Literals(["client", "server"]), + kind: Schema.tag("binary"), + body: Schema.String, + bodyEncoding: Schema.Literal("base64"), + }), +]) + +export type WebSocketEvent = Schema.Schema.Type + +export const WebSocketInteractionSchema = Schema.Struct({ + transport: Schema.tag("websocket"), + connection: Schema.optional( + Schema.Struct({ + sequence: Schema.Number, + url: Schema.String, + protocols: Schema.Array(Schema.String), + close: Schema.Struct({ + code: Schema.Number, + reason: Schema.String, + }), + }), + ), + open: Schema.optional( + Schema.Struct({ + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + }), + ), + events: Schema.Array(WebSocketEventSchema), +}) + +export interface WebSocketInteraction extends Schema.Schema.Type {} diff --git a/packages/http-recorder/src/websocket/recorder.ts b/packages/http-recorder/src/websocket/recorder.ts new file mode 100644 index 000000000000..ade1a70835a0 --- /dev/null +++ b/packages/http-recorder/src/websocket/recorder.ts @@ -0,0 +1,607 @@ +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Deferred, Effect, Exit, FiberSet, Layer, Option, Ref, Scope, Semaphore } from "effect" +import { Socket } from "effect/unstable/socket" +import { fileSystem, type Interface, Service } from "../cassette/store.js" +import type { SocketRecorderOptions } from "../options.js" +import { make, type Redactor } from "../redaction/redactor.js" +import { canonicalizeJson, decodeJson, safeText } from "../replay/comparison.js" +import { makeReplayState, resolveAutoMode } from "../replay/state.js" +import { webSocketInteractions, type Interaction } from "../cassette/model.js" +import type { WebSocketEvent, WebSocketInteraction } from "./model.js" + +export interface WebSocketRecorderOptions extends SocketRecorderOptions { + readonly compareClientMessagesAsJson?: boolean + readonly open?: { readonly url: string; readonly headers: Record } +} +interface ActiveReplay { + readonly interaction: WebSocketInteraction + readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred }> + readonly writeLock: Semaphore.Semaphore + readonly closed: Ref.Ref +} +interface ActiveRecording { + readonly events: Array + readonly eventLock: Semaphore.Semaphore + readonly accepting: Ref.Ref + opened: boolean + valid: boolean +} +interface PendingRecordings { + readonly promises: Set> + readonly errors: Array +} +type Frame = string | Uint8Array + +const normalizeProtocols = (protocols?: string | Array): Array => + protocols === undefined ? [] : typeof protocols === "string" ? [protocols] : [...protocols] +const frameFromWebSocketData = async (data: unknown): Promise => { + if (typeof data === "string") return data + if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer()) + if (data instanceof ArrayBuffer) return new Uint8Array(data) + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice() + throw new Error(`Unsupported WebSocket frame: ${Object.prototype.toString.call(data)}`) +} +const closeEvent = (code: number, reason: string): CloseEvent => { + if (typeof globalThis.CloseEvent === "function") + return new globalThis.CloseEvent("close", { code, reason, wasClean: code === 1000 }) + const event = new Event("close") + Object.defineProperties(event, { + code: { value: code }, + reason: { value: reason }, + wasClean: { value: code === 1000 }, + }) + return event as CloseEvent +} +const errorEvent = (error: unknown): ErrorEvent => { + if (typeof globalThis.ErrorEvent === "function") + return new globalThis.ErrorEvent("error", { + error, + message: error instanceof Error ? error.message : String(error), + }) + const event = new Event("error") + Object.defineProperties(event, { + error: { value: error }, + message: { value: error instanceof Error ? error.message : String(error) }, + }) + return event as ErrorEvent +} +const webSocketFacade = ( + target: EventTarget, + properties: { + readonly url: () => string + readonly readyState: () => number + readonly protocol: () => string + readonly extensions: () => string + readonly bufferedAmount: () => number + readonly send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void + readonly close: (code?: number, reason?: string) => void + }, +): globalThis.WebSocket => { + Object.defineProperties(target, { + url: { get: properties.url }, + readyState: { get: properties.readyState }, + protocol: { get: properties.protocol }, + extensions: { get: properties.extensions }, + bufferedAmount: { get: properties.bufferedAmount }, + binaryType: { value: "blob", writable: true }, + send: { value: properties.send }, + close: { value: properties.close }, + CONNECTING: { value: 0 }, + OPEN: { value: 1 }, + CLOSING: { value: 2 }, + CLOSED: { value: 3 }, + }) + for (const name of ["open", "message", "error", "close"] as const) { + let handler: ((event: Event) => unknown) | null = null + Object.defineProperty(target, `on${name}`, { + get: () => handler, + set: (next) => { + if (handler) target.removeEventListener(name, handler) + handler = typeof next === "function" ? next : null + if (handler) target.addEventListener(name, handler) + }, + }) + } + return target as globalThis.WebSocket +} + +const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent => + typeof message === "string" + ? { direction, kind: "text", body: message } + : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } +const decodeEvent = (event: WebSocketEvent): Frame => + event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) +const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => { + if (event.kind === "binary") return event + const body = + event.direction === "client" + ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body + : redactor.response({ status: 101, headers: {}, body: event.body }).body + return { ...event, body } +} +const comparable = (event: WebSocketEvent, asJson: boolean) => { + if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event)) + const decoded = decodeJson(event.body) + return JSON.stringify( + canonicalizeJson({ ...event, body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value) }), + ) +} +const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => + Effect.sync(() => { + if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return + throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) + }) +const runHandler = (handler: (value: A) => Effect.Effect | void, value: A) => + Effect.suspend(() => { + const result = handler(value) + return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void + }) +const runReplay = ( + state: ActiveReplay, + handler: (value: A) => Effect.Effect | void, + decode: (event: WebSocketEvent) => A, + onOpen: Effect.Effect | undefined, +) => + Effect.scoped( + Effect.gen(function* () { + const handlers = yield* FiberSet.make() + const run = yield* FiberSet.runtime(handlers)() + if (onOpen) yield* onOpen + const drive = Effect.gen(function* () { + while (true) { + const current = yield* Ref.get(state.progress) + const event = state.interaction.events[current.position] + if (!event) return + if (yield* Ref.get(state.closed)) + return yield* Effect.die( + new Error( + `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, + ), + ) + if (event.direction === "server") { + yield* Ref.set(state.progress, { position: current.position + 1, changed: yield* Deferred.make() }) + run(runHandler(handler, decode(event))) + continue + } + yield* Deferred.await(current.changed) + } + }) + yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers))) + yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers))) + }), + ) + +const makeRecordingSocket = ( + upstream: Socket.Socket, + cassette: Interface, + name: string, + options: WebSocketRecorderOptions, + redactor: Redactor, +) => + Effect.gen(function* () { + const active = yield* Ref.make(undefined) + const writeLock = yield* Semaphore.make(1) + return Socket.make({ + runRaw: (handler, runOptions) => + Effect.gen(function* () { + const state: ActiveRecording = { + events: [], + eventLock: yield* Semaphore.make(1), + accepting: yield* Ref.make(true), + opened: false, + valid: true, + } + const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) + if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported") + yield* upstream + .runRaw( + (message) => { + if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing") + state.events.push(redactEvent(encodeEvent("server", message), redactor)) + return handler(message) + }, + { + ...runOptions, + onOpen: Effect.gen(function* () { + state.opened = true + if (runOptions?.onOpen) yield* runOptions.onOpen + }), + }, + ) + .pipe( + Effect.onExit((exit) => + writeLock.withPermit( + state.eventLock.withPermit( + Effect.gen(function* () { + yield* Ref.set(state.accepting, false) + yield* Ref.set(active, undefined) + if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return + const open = options.open + ? redactor.request({ + method: "GET", + url: options.open.url, + headers: options.open.headers, + body: "", + }) + : undefined + yield* cassette + .append( + name, + { + transport: "websocket", + open: open ? { url: open.url, headers: open.headers } : undefined, + events: [...state.events], + }, + options.metadata, + ) + .pipe(Effect.orDie) + }), + ), + ), + ), + ) + }), + writer: upstream.writer.pipe( + Effect.map( + (write) => (message) => + writeLock.withPermit( + Effect.gen(function* () { + if (Socket.isCloseEvent(message)) return yield* write(message) + const state = yield* Ref.get(active) + if (!state || !(yield* Ref.get(state.accepting))) + return yield* Effect.die("WebSocket writer used without an active socket run") + const event = redactEvent(encodeEvent("client", message), redactor) + yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event))) + return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false)))) + }), + ), + ), + ), + }) + }) + +const makeReplaySocket = ( + cassette: Interface, + name: string, + options: WebSocketRecorderOptions, + redactor: Redactor, +): Effect.Effect => + Effect.gen(function* () { + const replay = yield* makeReplayState(cassette, name, webSocketInteractions) + const active = yield* Ref.make(undefined) + const runLock = yield* Semaphore.make(1) + const expectedOpen = options.open + ? redactor.request({ method: "GET", url: options.open.url, headers: options.open.headers, body: "" }) + : undefined + return Socket.make({ + runRaw: (handler, runOptions) => + runLock + .withPermitsIfAvailable(1)( + Effect.gen(function* () { + const claimed = yield* replay + .claim((interaction, index) => + Effect.sync(() => { + if (!interaction) throw new Error("Missing recorded WebSocket interaction") + if (!expectedOpen) return + const incoming = { url: expectedOpen.url, headers: expectedOpen.headers } + if ( + interaction.open && + JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open)) + ) + return + throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`) + }), + ) + .pipe(Effect.orDie) + const state = { + interaction: claimed.interaction, + progress: yield* Ref.make({ position: 0, changed: yield* Deferred.make() }), + writeLock: yield* Semaphore.make(1), + closed: yield* Ref.make(false), + } + yield* Ref.set(active, state) + yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe( + Effect.ensuring(Ref.set(active, undefined)), + ) + }), + ) + .pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.die("Concurrent runs of a replayed WebSocket are not supported"), + onSome: () => Effect.void, + }), + ), + ), + writer: Effect.succeed((message) => + Ref.get(active).pipe( + Effect.flatMap((state) => + state + ? state.writeLock.withPermit( + Effect.gen(function* () { + const current = yield* Ref.get(state.progress) + if (Socket.isCloseEvent(message)) { + yield* Ref.set(state.closed, true) + yield* Deferred.succeed(current.changed, undefined) + if (current.position === state.interaction.events.length) return + return yield* Effect.die( + new Error( + `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, + ), + ) + } + const actual = redactEvent(encodeEvent("client", message), redactor) + yield* assertEvent( + actual, + state.interaction.events[current.position], + current.position, + options.compareClientMessagesAsJson === true, + ) + yield* Ref.set(state.progress, { + position: current.position + 1, + changed: yield* Deferred.make(), + }) + yield* Deferred.succeed(current.changed, undefined) + }), + ) + : Effect.die("WebSocket writer used without an active socket run"), + ), + ), + ), + }) + }) + +const recordingLayer = ( + name: string, + options: WebSocketRecorderOptions, + forcedMode?: "record" | "replay", +): Layer.Layer => + Layer.effect( + Socket.Socket, + Effect.gen(function* () { + const upstream = yield* Socket.Socket + const cassette = yield* Service + const redactor = make(options.redact) + if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record") + return yield* makeRecordingSocket(upstream, cassette, name, options, redactor) + return yield* makeReplaySocket(cassette, name, options, redactor) + }), + ) + +export const layerSocket = ( + name: string, + options: SocketRecorderOptions = {}, +): Layer.Layer => + provideCassette(recordingLayer(name, { ...options, compareClientMessagesAsJson: true }), options) +/** @internal */ +export const layerSocketWithMode = ( + name: string, + options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" }, +): Layer.Layer => + provideCassette(recordingLayer(name, options, options.mode), options) +const provideCassette = (layer: Layer.Layer, options: WebSocketRecorderOptions) => + layer.pipe(Layer.provide(fileSystem({ directory: options.directory })), Layer.provide(NodeFileSystem.layer)) + +const makeRecordingWebSocketConstructor = ( + upstream: Socket.WebSocketConstructor["Service"], + cassette: Interface, + name: string, + metadata: SocketRecorderOptions["metadata"], + redactor: Redactor, + pending: PendingRecordings, +): Socket.WebSocketConstructor["Service"] => { + let nextSequence = 0 + return (url, protocols) => { + const sequence = nextSequence++ + const requestedProtocols = normalizeProtocols(protocols) + const native = upstream(url, requestedProtocols) + const events: WebSocketEvent[] = [] + let opened = false + let failed = false + let closed = false + let queue = Promise.resolve() + const appendEvent = (direction: "client" | "server", data: unknown) => { + queue = queue.then(async () => { + if (failed || closed) return + try { + events.push(redactEvent(encodeEvent(direction, await frameFromWebSocketData(data)), redactor)) + } catch { + failed = true + } + }) + } + const onOpen = () => { + opened = true + } + const onMessage = (event: MessageEvent) => { + appendEvent("server", event.data) + } + const onError = () => { + failed = true + } + const onClose = (event: CloseEvent) => { + native.removeEventListener("open", onOpen) + native.removeEventListener("message", onMessage) + native.removeEventListener("error", onError) + native.removeEventListener("close", onClose) + const completion = queue.then(async () => { + closed = true + if (opened && !failed) { + const request = redactor.request({ method: "WEBSOCKET", url, headers: {}, body: "" }) + const interaction: WebSocketInteraction = { + transport: "websocket", + connection: { + sequence, + url: request.url, + protocols: requestedProtocols, + close: { code: event.code, reason: event.reason }, + }, + events: [...events], + } + events.length = 0 + await Effect.runPromise(cassette.append(name, interaction, metadata).pipe(Effect.orDie)) + } + }) + pending.promises.add(completion) + void completion.then( + () => pending.promises.delete(completion), + (error) => { + pending.promises.delete(completion) + pending.errors.push(error) + }, + ) + } + native.addEventListener("open", onOpen) + native.addEventListener("message", onMessage) + native.addEventListener("error", onError) + native.addEventListener("close", onClose) + return new Proxy(native, { + get: (target, property) => { + if (property === "send") + return (data: string | ArrayBufferLike | Blob | ArrayBufferView) => { + Reflect.apply(target.send, target, [data]) + appendEvent("client", data) + } + const value: unknown = Reflect.get(target, property, target) + return typeof value === "function" ? value.bind(target) : value + }, + set: (target, property, value) => Reflect.set(target, property, value, target), + }) + } +} + +const constructorWebSocketInteractions = (interactions: ReadonlyArray) => + webSocketInteractions(interactions) + .filter((interaction) => interaction.connection !== undefined) + .map((interaction, index) => ({ interaction, index })) + .toSorted((a, b) => a.interaction.connection!.sequence - b.interaction.connection!.sequence) + .map(({ interaction }) => interaction) + +const makeReplayWebSocketConstructor = ( + cassette: Interface, + name: string, + redactor: Redactor, +): Effect.Effect => + Effect.gen(function* () { + const replay = yield* makeReplayState(cassette, name, constructorWebSocketInteractions) + return (url, protocols) => { + const target = new EventTarget() + const requestedProtocols = normalizeProtocols(protocols) + const request = redactor.request({ method: "WEBSOCKET", url, headers: {}, body: "" }) + let readyState = 0 + let interaction: WebSocketInteraction | undefined + let position = 0 + let finished = false + let closeRequested = false + let operations = Promise.resolve() + const fail = (error: unknown) => { + if (finished) return + finished = true + readyState = 3 + target.dispatchEvent(errorEvent(error)) + } + const finish = () => { + if (finished || !interaction || position !== interaction.events.length) return + finished = true + readyState = 3 + const terminal = interaction.connection?.close ?? { code: 1000, reason: "" } + target.dispatchEvent(closeEvent(terminal.code, terminal.reason)) + } + const drive = () => { + if (!interaction || finished) return + while (interaction.events[position]?.direction === "server") { + const event = interaction.events[position++] + if (!event) break + target.dispatchEvent(new MessageEvent("message", { data: decodeEvent(event) })) + } + if (position === interaction.events.length) setTimeout(finish, 0) + } + Effect.runPromise( + replay + .claim((recorded, index) => + Effect.sync(() => { + if (!recorded) throw new Error(`Missing recorded WebSocket connection ${index + 1}`) + const connection = recorded.connection + if (!connection) throw new Error(`WebSocket interaction ${index + 1} has no connection metadata`) + if (connection.url !== request.url) + throw new Error( + `WebSocket connection ${index + 1}: expected URL ${safeText(connection.url)}, received ${safeText(request.url)}`, + ) + if ( + connection.protocols.length !== requestedProtocols.length || + connection.protocols.some((protocol, protocolIndex) => protocol !== requestedProtocols[protocolIndex]) + ) + throw new Error( + `WebSocket connection ${index + 1}: expected protocols ${safeText(connection.protocols)}, received ${safeText(requestedProtocols)}`, + ) + }), + ) + .pipe(Effect.orDie), + ).then((claimed) => { + if (closeRequested) return fail(new Error("WebSocket closed before it opened")) + interaction = claimed.interaction + readyState = 1 + target.dispatchEvent(new Event("open")) + drive() + }, fail) + return webSocketFacade(target, { + url: () => url, + readyState: () => readyState, + protocol: () => requestedProtocols[0] ?? "", + extensions: () => "", + bufferedAmount: () => 0, + send: (data) => { + if (!interaction || readyState !== 1 || closeRequested) throw new Error("WebSocket is not open") + operations = operations.then(async () => { + try { + const frame = await frameFromWebSocketData(data) + const actual = redactEvent(encodeEvent("client", frame), redactor) + Effect.runSync(assertEvent(actual, interaction?.events[position], position, true)) + position += 1 + drive() + } catch (error) { + fail(error) + } + }) + }, + close: () => { + if (closeRequested || readyState === 3) return + closeRequested = true + readyState = 2 + operations = operations.then(() => { + if (!interaction) return + if (position !== interaction.events.length) + return fail( + new Error(`WebSocket closed with unconsumed events: used ${position} of ${interaction.events.length}`), + ) + finish() + }) + }, + }) + } + }) + +export const layerWebSocketConstructor = ( + name: string, + options: SocketRecorderOptions = {}, +): Layer.Layer => + provideCassette( + Layer.effect( + Socket.WebSocketConstructor, + Effect.gen(function* () { + const upstream = yield* Socket.WebSocketConstructor + const cassette = yield* Service + const redactor = make(options.redact) + if ((yield* resolveAutoMode(cassette, name)) === "replay") + return yield* makeReplayWebSocketConstructor(cassette, name, redactor) + const pending: PendingRecordings = { promises: new Set(), errors: [] } + yield* Effect.addFinalizer(() => + Effect.promise(() => Promise.all(pending.promises)).pipe( + Effect.flatMap(() => (pending.errors.length === 0 ? Effect.void : Effect.die(pending.errors[0]))), + ), + ) + return makeRecordingWebSocketConstructor(upstream, cassette, name, options.metadata, redactor, pending) + }), + ), + options, + ) diff --git a/packages/http-recorder/sst-env.d.ts b/packages/http-recorder/sst-env.d.ts index 64441936d7a0..f25b9714550d 100644 --- a/packages/http-recorder/sst-env.d.ts +++ b/packages/http-recorder/sst-env.d.ts @@ -7,4 +7,4 @@ /// import "sst" -export {} \ No newline at end of file +export {} diff --git a/packages/http-recorder/test/cassette.test.ts b/packages/http-recorder/test/cassette.test.ts new file mode 100644 index 000000000000..970e65b10c31 --- /dev/null +++ b/packages/http-recorder/test/cassette.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test" +import { Effect } from "effect" +import { memory, Service } from "../src/cassette/store" + +test("memory cassettes append and replay interactions", async () => { + const interaction = { + transport: "http" as const, + request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, + response: { status: 200, headers: {}, body: "ok" }, + } + const stored = await Effect.runPromise( + Effect.gen(function* () { + const cassette = yield* Service + yield* cassette.append("example", interaction) + return yield* cassette.read("example") + }).pipe(Effect.provide(memory())), + ) + expect(stored).toEqual([interaction]) +}) diff --git a/packages/http-recorder/test/http.test.ts b/packages/http-recorder/test/http.test.ts new file mode 100644 index 000000000000..8a2f6a36cf82 --- /dev/null +++ b/packages/http-recorder/test/http.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test" +import { HttpRecorder } from "../src" + +test("public API exposes the upstream 0.3 layer contract with the V1 compatibility alias", () => { + expect(Object.keys(HttpRecorder).sort()).toEqual([ + "http", + "layer", + "layerFetch", + "layerSocket", + "layerWebSocketConstructor", + ]) +}) diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts index 93d7bbaded51..aec7b272fa86 100644 --- a/packages/http-recorder/test/record-replay.test.ts +++ b/packages/http-recorder/test/record-replay.test.ts @@ -3,13 +3,13 @@ import { describe, expect, test } from "bun:test" import { Cause, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Socket } from "effect/unstable/socket" -import * as fs from "node:fs" -import * as os from "node:os" -import * as path from "node:path" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" import { HttpRecorder } from "../src" import { HttpRecorderInternal } from "../src/internal" -import { redactedErrorRequest } from "../src/internal-effect" -import type { Interaction } from "../src/schema" +import type { Interaction } from "../src/cassette/model" +import { redactedErrorRequest } from "../src/http/recorder" const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => Effect.runPromise( @@ -34,13 +34,13 @@ const post = (url: string, body: object) => }) const run = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.http("record-replay/multi-step")))) + Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch("record-replay/multi-step")))) const runWith = ( name: string, options: HttpRecorder.RecorderOptions, effect: Effect.Effect, -) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.http(name, options)))) +) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch(name, options)))) const runRecorder = (effect: Effect.Effect) => Effect.runPromise( @@ -309,6 +309,46 @@ describe("http-recorder", () => { expect(received).toEqual(['{"type":"session.created"}', '{"type":"response.completed"}']) }) + test("WebSocket replay validates the private socket connection", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) + await seedCassetteDirectory(directory, "websocket/open-mismatch", [ + { + transport: "websocket", + open: { url: "wss://example.test/expected", headers: {} }, + events: [], + }, + ]) + + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + const socket = yield* Socket.Socket + yield* socket.runRaw(() => {}) + }).pipe( + Effect.scoped, + Effect.provide( + HttpRecorderInternal.socketLayer( + "websocket/open-mismatch", + { url: "wss://example.test/received" }, + { directory, mode: "replay" }, + ).pipe( + Layer.provide( + Layer.succeed( + Socket.Socket, + Socket.make({ + runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), + writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), + }), + ), + ), + ), + ), + ), + ) + + expect(failureText(exit)).toContain("WebSocket open 1 does not match") + expect(failureText(exit)).toContain("wss://example.test/received") + }) + test("the public socket decorator replays a provided Effect socket", async () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) await seedCassetteDirectory(directory, "websocket/public-layer", [ @@ -338,7 +378,7 @@ describe("http-recorder", () => { }).pipe( Effect.scoped, Effect.provide( - HttpRecorder.socket("websocket/public-layer", { directory }).pipe( + HttpRecorder.layerSocket("websocket/public-layer", { directory }).pipe( Layer.provide( Layer.succeed( Socket.Socket, @@ -569,6 +609,15 @@ describe("http-recorder", () => { ) }) + test("distinct requests replay in any order", async () => { + await run( + Effect.gen(function* () { + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}') + }), + ) + }) + test("concurrent replay claims each interaction once", async () => { const results = await runWith( "record-replay/retry", @@ -609,7 +658,7 @@ describe("http-recorder", () => { const exit = await Effect.runPromise( Effect.exit( post("https://example.test/echo", { step: 1 }).pipe( - Effect.provide(HttpRecorder.http("missing-cassette", { directory })), + Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory })), ), ), ) @@ -686,7 +735,7 @@ describe("http-recorder", () => { }) const responses = await Effect.runPromise( Effect.all([request("first"), request("second")], { concurrency: "unbounded" }).pipe( - Effect.provide(HttpRecorder.http("concurrent-order", { directory })), + Effect.provide(HttpRecorder.layerFetch("concurrent-order", { directory })), ), ) const cassette = JSON.parse(fs.readFileSync(path.join(directory, "concurrent-order.json"), "utf8")) @@ -739,7 +788,7 @@ describe("http-recorder", () => { return yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/empty`)) }) const response = await Effect.runPromise( - program.pipe(Effect.provide(HttpRecorder.http("no-content", { directory }))), + program.pipe(Effect.provide(HttpRecorder.layerFetch("no-content", { directory }))), ) expect(response.status).toBe(204) @@ -764,9 +813,13 @@ describe("http-recorder", () => { const response = yield* http.execute(HttpClientRequest.get(url)) return new Uint8Array(yield* response.arrayBuffer) }) - const record = await Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.http("binary", { directory })))) + const record = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory }))), + ) await server.stop() - const replay = await Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.http("binary", { directory })))) + const replay = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory }))), + ) const cassette = JSON.parse(fs.readFileSync(path.join(directory, "binary.json"), "utf8")) expect(record).toEqual(expected) diff --git a/packages/http-recorder/test/redaction.test.ts b/packages/http-recorder/test/redaction.test.ts new file mode 100644 index 000000000000..1437f170071f --- /dev/null +++ b/packages/http-recorder/test/redaction.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test" +import { make } from "../src/redaction/redactor" + +test("redacts common JSON secrets", () => { + const request = make().request({ + method: "POST", + url: "https://example.test", + headers: { authorization: "Bearer secret", "content-type": "application/json" }, + body: '{"access_token":"secret","safe":true}', + }) + expect(request.headers).toEqual({ "content-type": "application/json" }) + expect(request.body).toBe('{"access_token":"[REDACTED]","safe":true}') +}) diff --git a/packages/http-recorder/test/websocket.test.ts b/packages/http-recorder/test/websocket.test.ts new file mode 100644 index 000000000000..21dbb3ca3502 --- /dev/null +++ b/packages/http-recorder/test/websocket.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test" +import { Effect, Layer } from "effect" +import { Socket } from "effect/unstable/socket" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { HttpRecorder } from "../src" +import { decodeCassette } from "../src/cassette/model" + +test("accepts upstream WebSocket interactions and the OpenCode open snapshot extension", () => { + const cassette = decodeCassette({ + version: 1, + interactions: [ + { transport: "websocket", events: [] }, + { + transport: "websocket", + open: { url: "wss://example.test", headers: { "content-type": "application/json" } }, + events: [{ direction: "server", kind: "text", body: "ready" }], + }, + ], + }) + expect(cassette.interactions).toHaveLength(2) +}) + +test("constructor replay validates dynamic connection metadata without opening a live socket", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-constructor-")) + fs.writeFileSync( + path.join(directory, "constructor.json"), + JSON.stringify({ + version: 1, + interactions: [ + { + transport: "websocket", + connection: { + sequence: 0, + url: "wss://example.test/events", + protocols: ["events.v1"], + close: { code: 1000, reason: "complete" }, + }, + events: [], + }, + ], + }), + ) + + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://example.test/events", { + protocols: ["events.v1"], + closeCodeIsError: () => false, + }) + yield* socket.runString(() => {}) + }).pipe( + Effect.scoped, + Effect.provide( + HttpRecorder.layerWebSocketConstructor("constructor", { directory }).pipe( + Layer.provide( + Layer.succeed(Socket.WebSocketConstructor, () => { + throw new Error("unexpected live WebSocket construction") + }), + ), + ), + ), + ), + ) + fs.rmSync(directory, { recursive: true, force: true }) +}) diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index c4edaba2b49b..ffacc5003b8a 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -316,6 +316,6 @@ Filters apply in replay and record mode. Combine them with `RECORD=true` when re **Binary response bodies.** Most providers stream text (SSE, JSON). The recorder treats known textual media types (`text/*`, JSON/XML structured types, JavaScript, forms, YAML, and SVG) as text and stores every other response as base64 with `bodyEncoding: "base64"`. This preserves binary formats such as AWS event-stream frames without a lossy UTF-8 round trip. -**Matching strategy.** Replay walks the cassette in record order via an internal cursor: the Nth runtime request is served by the Nth recorded interaction, and each one is validated by comparing method, URL, allow-listed headers, and the canonical JSON body. This handles tool loops (each round's request differs as history grows) and retry/polling scenarios (successive byte-identical requests with different responses) uniformly. If a test reorders its requests, re-record the cassette. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. +**Matching strategy.** A runtime request atomically claims the first unused recorded interaction that matches its method, URL, allow-listed headers, and canonical JSON body. Distinct requests may replay in any order or concurrently. Repeated identical requests consume their matching responses in cassette order, preserving deterministic retry and polling behavior. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed. diff --git a/packages/llm/test/recorded-golden.ts b/packages/llm/test/recorded-golden.ts index 540662b2993e..404a5028b2a6 100644 --- a/packages/llm/test/recorded-golden.ts +++ b/packages/llm/test/recorded-golden.ts @@ -28,7 +28,7 @@ type TargetInput = { readonly transport?: Transport readonly prefix?: string readonly tags?: ReadonlyArray - readonly metadata?: Record + readonly metadata?: HttpRecorder.CassetteMetadata readonly options?: HttpRecorder.RecorderOptions readonly scenarios: ReadonlyArray } @@ -43,7 +43,7 @@ const defaultPrefix = (target: TargetInput) => { const metadata = (target: TargetInput) => ({ provider: target.model.provider, - protocol: target.protocol, + ...(target.protocol ? { protocol: target.protocol } : {}), route: target.model.route.id, transport: target.transport ?? "http", model: target.model.id, diff --git a/packages/llm/test/recorded-runner.ts b/packages/llm/test/recorded-runner.ts index 97d9b03f5462..904a9366acaf 100644 --- a/packages/llm/test/recorded-runner.ts +++ b/packages/llm/test/recorded-runner.ts @@ -1,3 +1,4 @@ +import type { HttpRecorder } from "@opencode-ai/http-recorder" import { test, type TestOptions } from "bun:test" import { Effect, type Layer } from "effect" import { testEffect } from "./lib/effect" @@ -11,7 +12,7 @@ export type RecordedGroupOptions = { readonly protocol?: string readonly requires?: ReadonlyArray readonly tags?: ReadonlyArray - readonly metadata?: Record + readonly metadata?: HttpRecorder.CassetteMetadata } export type RecordedCaseOptions = { @@ -21,7 +22,7 @@ export type RecordedCaseOptions = { readonly protocol?: string readonly requires?: ReadonlyArray readonly tags?: ReadonlyArray - readonly metadata?: Record + readonly metadata?: HttpRecorder.CassetteMetadata } export const recordedEffectGroup = < @@ -36,7 +37,7 @@ export const recordedEffectGroup = < readonly layer: (input: { readonly cassette: string readonly tags: ReadonlyArray - readonly metadata: Record + readonly metadata: HttpRecorder.CassetteMetadata readonly recording: boolean readonly options: Options readonly caseOptions: CaseOptions diff --git a/packages/llm/test/recorded-websocket.ts b/packages/llm/test/recorded-websocket.ts index afeee09b77e6..0b6c64f2151d 100644 --- a/packages/llm/test/recorded-websocket.ts +++ b/packages/llm/test/recorded-websocket.ts @@ -1,3 +1,4 @@ +import type { HttpRecorder } from "@opencode-ai/http-recorder" import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { Effect, Layer } from "effect" import { WebSocketExecutor } from "../src/route" @@ -7,7 +8,7 @@ const liveWebSocket = WebSocketExecutor.open export const webSocketCassetteLayer = ( cassette: string, - input: { readonly metadata?: Record; readonly mode: HttpRecorderInternal.RecordReplayMode }, + input: { readonly metadata?: HttpRecorder.CassetteMetadata; readonly mode: HttpRecorderInternal.RecordReplayMode }, ): Layer.Layer => Layer.effect( WebSocketExecutor.Service, From fe293af3b7fe0fd12fd0bfb6d5c91b60902f30ec Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 6 Jul 2026 20:13:42 -0400 Subject: [PATCH 2/3] refactor(http-recorder): remove compatibility seams --- .../core/test/session-runner-recorded.test.ts | 18 +- packages/http-recorder/README.md | 72 +- packages/http-recorder/package.json | 5 +- packages/http-recorder/script/build.ts | 12 +- packages/http-recorder/script/pack.ts | 10 + .../http-recorder/script/verify-package.ts | 54 +- packages/http-recorder/src/index.ts | 6 +- packages/http-recorder/src/internal.ts | 26 - .../http-recorder/src/websocket/executor.ts | 164 --- packages/http-recorder/src/websocket/model.ts | 6 - .../http-recorder/src/websocket/recorder.ts | 29 +- packages/http-recorder/test/cassette.test.ts | 191 +++- .../{record-replay => http}/multi-step.json | 0 .../{record-replay => http}/retry.json | 0 packages/http-recorder/test/http.test.ts | 334 ++++++- .../http-recorder/test/record-replay.test.ts | 932 ------------------ packages/http-recorder/test/redaction.test.ts | 178 +++- packages/http-recorder/test/support.ts | 61 ++ packages/http-recorder/test/tsconfig.json | 11 + packages/http-recorder/test/websocket.test.ts | 560 ++++++++++- packages/llm/AGENTS.md | 2 +- .../llm/test/provider/golden.recorded.test.ts | 9 - packages/llm/test/recorded-test.ts | 33 +- packages/llm/test/recorded-websocket.ts | 27 - .../test/session/llm-native-recorded.test.ts | 19 +- 25 files changed, 1384 insertions(+), 1375 deletions(-) delete mode 100644 packages/http-recorder/src/internal.ts delete mode 100644 packages/http-recorder/src/websocket/executor.ts rename packages/http-recorder/test/fixtures/recordings/{record-replay => http}/multi-step.json (100%) rename packages/http-recorder/test/fixtures/recordings/{record-replay => http}/retry.json (100%) delete mode 100644 packages/http-recorder/test/record-replay.test.ts create mode 100644 packages/http-recorder/test/support.ts create mode 100644 packages/http-recorder/test/tsconfig.json delete mode 100644 packages/llm/test/recorded-websocket.ts diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index ce63afdfcc5c..d881c25efd4d 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -1,5 +1,4 @@ import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route" import { Database } from "@opencode-ai/core/database/database" @@ -40,18 +39,17 @@ import { McpGuidance } from "@opencode-ai/core/mcp/guidance" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" +import { rmSync } from "node:fs" import path from "node:path" import { testEffect } from "./lib/effect" -const cassette = - process.env.RECORD === "true" - ? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", { - directory: path.resolve(import.meta.dir, "fixtures/recordings"), - mode: "record", - }) - : HttpRecorder.layerFetch("session-runner/openai-chat-streams-text", { - directory: path.resolve(import.meta.dir, "fixtures/recordings"), - }) +const cassetteName = "session-runner/openai-chat-streams-text" +const cassetteDirectory = path.resolve(import.meta.dir, "fixtures/recordings") +if (process.env.RECORD === "true") { + if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") + rmSync(path.join(cassetteDirectory, `${cassetteName}.json`), { force: true }) +} +const cassette = HttpRecorder.layerFetch(cassetteName, { directory: cassetteDirectory }) const executor = RequestExecutor.layer.pipe(Layer.provide(cassette)) const client = LLMClient.layer.pipe(Layer.provide(executor)) const permission = Layer.succeed( diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index 6f3106b1f795..c0fab081b823 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -87,45 +87,73 @@ HttpRecorder.layerSocket(name, options?) HttpRecorder.layerWebSocketConstructor(name, options?) ``` -That is the complete public API. `layer` decorates a provided `HttpClient`; `layerFetch` supplies Effect's fetch client. `layerSocket` decorates a provided `Socket.Socket`, while `layerWebSocketConstructor` records dynamic WebSocket URLs and protocols. +That is the complete runtime API. `layer` decorates an application-provided `HttpClient`; `layerFetch` is the convenience layer that supplies Effect's fetch client. `layerWebSocketConstructor` decorates Effect's `Socket.WebSocketConstructor`, recording every dynamically selected URL and protocol. `layerSocket` is the lower-level transport-neutral decorator for an application-provided `Socket.Socket`. + +Use `layer` to record through another Effect HTTP transport: + +```ts +import { NodeHttpClient } from "@effect/platform-node" +import { Layer } from "effect" + +const recorder = HttpRecorder.layer("users/get-one").pipe(Layer.provide(NodeHttpClient.layerUndici)) +``` + +The `HttpRecorder` namespace also exposes the configuration types `RecorderOptions`, `SocketRecorderOptions`, `RedactOptions`, `RequestMatcher`, `RequestSnapshot`, and `CassetteMetadata`. ## WebSockets -WebSocket cassettes preserve one ordered transcript of client and server text or binary frames. Replay follows that chronology: server frames are released until the next recorded client frame, then replay waits for the application to send the matching frame before continuing. +Real applications often select WebSocket URLs inside domain services. Effect represents that capability with `Socket.WebSocketConstructor`; production supplies the platform implementation, while tests can decorate it without changing application code. ```ts -import { assert, it } from "@effect/vitest" import { NodeSocket } from "@effect/platform-node" -import { Effect, Layer } from "effect" +import { it } from "@effect/vitest" +import { Deferred, Effect, Layer } from "effect" import { Socket } from "effect/unstable/socket" import { HttpRecorder } from "@opencode-ai/http-recorder" -const echo = Effect.gen(function* () { - const socket = yield* Socket.Socket +const roundTrip = Effect.fn("Echo.roundTrip")(function* (url: string, message: string) { + const socket = yield* Socket.makeWebSocket(url, { closeCodeIsError: () => false }) const write = yield* socket.writer + const echoed = yield* Deferred.make() yield* socket.runString( - (message) => - Effect.gen(function* () { - assert.strictEqual(message, "hello") - yield* write(new Socket.CloseEvent(1000)) - }), - { onOpen: write("hello") }, + (response) => { + return Deferred.succeed(echoed, response).pipe( + Effect.andThen(write(new Socket.CloseEvent(1000, "done"))), + Effect.orDie, + ) + }, + { onOpen: write(message).pipe(Effect.orDie) }, ) + + return yield* Deferred.await(echoed) }) -const recordedSocket = HttpRecorder.layerSocket("echo/hello").pipe( - Layer.provide( - NodeSocket.layerWebSocket("wss://ws.postman-echo.com/raw", { - closeCodeIsError: (code) => code !== 1000, - }), +it.effect("round trips a message", () => + roundTrip("wss://ws.postman-echo.com/raw", "hello").pipe( + Effect.scoped, + Effect.provide( + HttpRecorder.layerWebSocketConstructor("echo/round-trip").pipe( + Layer.provide(NodeSocket.layerWebSocketConstructor), + ), + ), ), ) - -it.effect("exchanges WebSocket frames", () => echo.pipe(Effect.provide(recordedSocket))) ``` -The application owns the WebSocket URL and protocols through normal Effect layer wiring. The recorder wraps that socket without duplicating its URL in recorder configuration. Provide separate socket layers for separate endpoints or concurrent connections. +The production application supplies only `NodeSocket.layerWebSocketConstructor`. The recorder appears in test wiring and observes each call to `Socket.makeWebSocket`, including URLs selected at runtime. + +`socket.runString` owns the receive loop and finishes when the connection closes or fails. Its optional `onOpen` effect is the safe place to send protocols whose client speaks first. The writer is scoped because sending is valid only while a connection run is active. + +WebSocket cassettes preserve one ordered transcript of client and server text or binary frames. Replay releases recorded server frames until it reaches a client frame, waits for the application to write the matching frame, then continues. This preserves causal ordering without reproducing network timing. + +Client text frames containing JSON compare canonically, so object-key order does not matter. Changed fields, extra fields, non-JSON text, and binary frames must match exactly after redaction. There is intentionally no custom WebSocket matcher in this beta. + +Incoming frame handlers start in recorded order and may run concurrently, matching Effect's socket abstraction. Replay waits for all handlers before the socket run completes, but handler completion order is not guaranteed. Use Effect synchronization such as `Queue`, `Ref`, or `Deferred` instead of unsynchronized mutable state. + +A constructor cassette records the URL, requested protocols, frames, and terminal close for each connection. Replay validates the URL and protocols before opening the simulated socket. Closing before every recorded frame is consumed fails the test. + +Use `layerSocket` when a protocol layer already consumes one application-provided `Socket.Socket`, including non-WebSocket transports. Because that lower-level abstraction has no URL or protocols, its cassettes use the cassette name and connection order as identity. Text frames use the same JSON-field and body redaction as HTTP bodies. Binary frames are stored losslessly as base64. Client and server frame kinds must match during replay. @@ -177,7 +205,7 @@ A runtime request atomically claims the first unused recorded interaction that m Repeated identical requests consume their matching responses in cassette order, which models retries, polling, and cache tests deterministically. A mismatch consumes nothing, and JSON object keys are canonicalized before matching. -Concurrent requests are recorded in request-start order even when their responses complete out of order. +Concurrent requests are recorded in request-start order even when their responses complete out of order. Each recorded interaction can be claimed only once, and leaving interactions unused fails when the recorder layer closes. Supply a custom equivalence rule when a request contains intentionally volatile data: @@ -203,6 +231,8 @@ type SocketRecorderOptions = Omit `directory` defaults to `/test/fixtures/recordings`. +See [`examples/`](./examples) for complete HTTP and WebSocket examples. + ## Cassettes Cassettes are readable JSON files intended to be committed with your tests. HTTP interactions are stored in request order. WebSocket cassettes preserve the observed order of client and server frames. Text stays readable; binary bodies and frames are stored losslessly as base64. diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 9437671a2a25..b3166190f68f 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -28,13 +28,12 @@ }, "scripts": { "test": "bun test --timeout 30000 --only-failures", - "typecheck": "tsgo --noEmit", + "typecheck": "tsgo --noEmit && tsgo -p test/tsconfig.json --noEmit", "build": "bun ./script/build.ts", "verify:package": "bun ./script/verify-package.ts" }, "exports": { - ".": "./src/index.ts", - "./internal": "./src/internal.ts" + ".": "./src/index.ts" }, "sideEffects": false, "files": [ diff --git a/packages/http-recorder/script/build.ts b/packages/http-recorder/script/build.ts index 636c65017796..9d9de65e6254 100644 --- a/packages/http-recorder/script/build.ts +++ b/packages/http-recorder/script/build.ts @@ -1,12 +1,12 @@ #!/usr/bin/env bun import { $ } from "bun" -import { rm } from "node:fs/promises" +import { readdir, rm } from "node:fs/promises" await rm("dist", { recursive: true, force: true }) await $`bunx tsc --emitDeclarationOnly` const build = await Bun.build({ - entrypoints: ["src/index.ts", "src/internal.ts"], + entrypoints: ["src/index.ts"], outdir: "dist", target: "node", format: "esm", @@ -14,7 +14,13 @@ const build = await Bun.build({ }) if (!build.success) throw new AggregateError(build.logs, "Failed to build @opencode-ai/http-recorder") -for (const file of ["dist/index.d.ts", "dist/api.d.ts", "dist/internal.d.ts"]) { +await Promise.all( + (await readdir("dist", { recursive: true })) + .filter((file) => file.endsWith(".d.ts") && file !== "index.d.ts" && file !== "api.d.ts") + .map((file) => rm(`dist/${file}`)), +) + +for (const file of ["dist/index.d.ts", "dist/api.d.ts"]) { if ((await Bun.file(file).text()).includes(["import", "("].join(""))) throw new Error(`${file} contains dynamic import syntax`) } diff --git a/packages/http-recorder/script/pack.ts b/packages/http-recorder/script/pack.ts index 11cc4668b883..c40d83c84972 100644 --- a/packages/http-recorder/script/pack.ts +++ b/packages/http-recorder/script/pack.ts @@ -28,4 +28,14 @@ export const pack = async () => { } } +export const withPackedArchive = async (use: (archive: string) => Promise) => { + const archive = await pack() + try { + return await use(archive) + } finally { + const file = Bun.file(archive) + if (await file.exists()) await file.delete() + } +} + if (import.meta.main) await pack() diff --git a/packages/http-recorder/script/verify-package.ts b/packages/http-recorder/script/verify-package.ts index 32806fbc7a39..83e55518251a 100644 --- a/packages/http-recorder/script/verify-package.ts +++ b/packages/http-recorder/script/verify-package.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" -import { pack } from "./pack.js" +import { withPackedArchive } from "./pack.js" const run = async (command: ReadonlyArray, cwd: string) => { const process = Bun.spawn(command, { cwd, env: globalThis.process.env, stdout: "inherit", stderr: "inherit" }) @@ -10,6 +10,11 @@ const run = async (command: ReadonlyArray, cwd: string) => { if (exitCode !== 0) throw new Error(`${command.join(" ")} exited with code ${exitCode}`) } +const reject = async (command: ReadonlyArray, cwd: string) => { + const process = Bun.spawn([...command], { cwd, env: globalThis.process.env, stdout: "ignore", stderr: "ignore" }) + if ((await process.exited) === 0) throw new Error(`${command.join(" ")} unexpectedly succeeded`) +} + export const verifyPackage = async (archive: string) => { const directory = await mkdtemp(path.join(tmpdir(), "http-recorder-consumer-")) try { @@ -20,7 +25,6 @@ export const verifyPackage = async (archive: string) => { await writeFile( path.join(directory, "consumer.ts"), `import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { NodeSocket } from "@effect/platform-node" import { Layer } from "effect" import { HttpClient } from "effect/unstable/http" @@ -30,17 +34,34 @@ const options: HttpRecorder.RecorderOptions = { match: () => true, redact: { jso const socketOptions: HttpRecorder.SocketRecorderOptions = { redact: { jsonFields: ["access_token"] } } HttpRecorder.layer("consumer", options) satisfies Layer.Layer HttpRecorder.layerFetch("consumer", options) satisfies Layer.Layer -HttpRecorder.http("consumer", options) satisfies Layer.Layer HttpRecorder.layerSocket("consumer/socket", socketOptions).pipe( Layer.provide(NodeSocket.layerWebSocket("wss://example.test")), ) satisfies Layer.Layer HttpRecorder.layerWebSocketConstructor("consumer/websocket", socketOptions).pipe( Layer.provide(NodeSocket.layerWebSocketConstructor), ) satisfies Layer.Layer -HttpRecorderInternal.hasCassetteSync satisfies (name: string, options?: { readonly directory?: string }) => boolean -HttpRecorderInternal.Cassette.memory() // @ts-expect-error HTTP request matching does not apply to WebSocket frames. HttpRecorder.layerSocket("consumer/socket", { match: () => true }) +`, + ) + await writeFile( + path.join(directory, "exports.mjs"), + `import { HttpRecorder } from "@opencode-ai/http-recorder" + +const root = Object.keys(await import("@opencode-ai/http-recorder")).sort() +if (JSON.stringify(root) !== JSON.stringify(["HttpRecorder"])) { + throw new Error(\`Unexpected root exports: \${root}\`) +} + +const namespace = Object.keys(HttpRecorder).sort() +if (JSON.stringify(namespace) !== JSON.stringify(["layer", "layerFetch", "layerSocket", "layerWebSocketConstructor"])) { + throw new Error(\`Unexpected HttpRecorder exports: \${namespace}\`) +} +`, + ) + await writeFile( + path.join(directory, "deep-import.mjs"), + `import "@opencode-ai/http-recorder/internal" `, ) await writeFile( @@ -52,7 +73,7 @@ HttpRecorder.layerSocket("consumer/socket", { match: () => true }) moduleResolution: "NodeNext", strict: true, noEmit: true, - // Required by effect@4.0.0-beta.74: its schema.d.ts references an undeclared SchemaErrorTypeId. + // Required by effect@4.0.0-beta.83: its declarations currently contain unresolved internal symbols. skipLibCheck: true, lib: ["ES2022", "DOM", "ESNext.Disposable"], }, @@ -75,26 +96,13 @@ HttpRecorder.layerSocket("consumer/socket", { match: () => true }) ], directory, ) - await run( - [ - "node", - "--input-type=module", - "-e", - 'Promise.all([import("@opencode-ai/http-recorder"), import("@opencode-ai/http-recorder/internal")]).then(([module, internal]) => { const root = Object.keys(module).sort(); const namespace = Object.keys(module.HttpRecorder).sort(); if (JSON.stringify(root) !== JSON.stringify(["HttpRecorder"])) throw new Error(`Unexpected root exports: ${root}`); if (JSON.stringify(namespace) !== JSON.stringify(["http", "layer", "layerFetch", "layerSocket", "layerWebSocketConstructor"])) throw new Error(`Unexpected namespace exports: ${namespace}`); if (!("Cassette" in internal) || !("makeWebSocketExecutor" in internal)) throw new Error("Missing internal compatibility exports") })', - ], - directory, - ) + await run(["node", path.join(directory, "exports.mjs")], directory) + await run(["bun", path.join(directory, "exports.mjs")], directory) + await reject(["node", path.join(directory, "deep-import.mjs")], directory) await run([path.join(directory, "node_modules", ".bin", "tsc"), "--noEmit"], directory) } finally { await rm(directory, { recursive: true, force: true }) } } -if (import.meta.main) { - const archive = await pack() - try { - await verifyPackage(archive) - } finally { - await Bun.file(archive).delete() - } -} +if (import.meta.main) await withPackedArchive(verifyPackage) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts index 5f8c0fb240e2..6a1cd2a135e9 100644 --- a/packages/http-recorder/src/index.ts +++ b/packages/http-recorder/src/index.ts @@ -12,8 +12,6 @@ export const HttpRecorder: { options?: Api.RecorderOptions, ) => Layer.Layer readonly layerFetch: (name: string, options?: Api.RecorderOptions) => Layer.Layer - /** @deprecated Use `layerFetch`. Retained for the V1 OpenCode test harness. */ - readonly http: (name: string, options?: Api.RecorderOptions) => Layer.Layer readonly layerSocket: ( name: string, options?: Api.SocketRecorderOptions, @@ -22,10 +20,10 @@ export const HttpRecorder: { name: string, options?: Api.SocketRecorderOptions, ) => Layer.Layer -} = { layer, layerFetch, http: layerFetch, layerSocket, layerWebSocketConstructor } +} = { layer, layerFetch, layerSocket, layerWebSocketConstructor } export namespace HttpRecorder { - /** JSON-compatible cassette metadata value. */ + /** Additional JSON metadata stored with a cassette. */ export type JsonValue = Api.JsonValue /** Additional JSON metadata stored with a cassette. */ export type CassetteMetadata = Api.CassetteMetadata diff --git a/packages/http-recorder/src/internal.ts b/packages/http-recorder/src/internal.ts deleted file mode 100644 index 92ebf81386d5..000000000000 --- a/packages/http-recorder/src/internal.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { layerSocketWithMode, type WebSocketRecorderOptions } from "./websocket/recorder.js" -import { Layer } from "effect" -import { Socket } from "effect/unstable/socket" - -export { CassetteNotFoundError, hasCassetteSync, InvalidCassetteError, UnsafeCassetteError } from "./cassette/store.js" -export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./http/recorder.js" -export { redactHeaders, redactUrl } from "./redaction/redactor.js" -export { secretFindings, type SecretFinding } from "./redaction/secrets.js" -export { layerSocketWithMode } from "./websocket/recorder.js" -export const socketLayer = ( - name: string, - connection: { readonly url: string; readonly headers?: Record }, - options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" }, -): Layer.Layer => - layerSocketWithMode(name, { ...options, open: { url: connection.url, headers: connection.headers ?? {} } }) -export { - makeWebSocketExecutor, - type WebSocketConnection, - type WebSocketExecutor, - type WebSocketRecordReplayOptions, - type WebSocketRequest, -} from "./websocket/executor.js" -export * as Cassette from "./cassette/store.js" -export * as Redactor from "./redaction/redactor.js" - -export * as HttpRecorderInternal from "./internal.js" diff --git a/packages/http-recorder/src/websocket/executor.ts b/packages/http-recorder/src/websocket/executor.ts deleted file mode 100644 index 8a72ddf19dd2..000000000000 --- a/packages/http-recorder/src/websocket/executor.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { Effect, Option, Ref, Scope, Semaphore, Stream, SynchronizedRef } from "effect" -import type { Headers } from "effect/unstable/http" -import type { Interface } from "../cassette/store.js" -import { webSocketInteractions, type CassetteMetadata } from "../cassette/model.js" -import type { RecordReplayMode } from "../http/recorder.js" -import { make, type Redactor } from "../redaction/redactor.js" -import { canonicalizeJson, decodeJson, safeText } from "../replay/comparison.js" -import { makeReplayState, resolveAutoMode } from "../replay/state.js" -import type { WebSocketEvent } from "./model.js" - -export interface WebSocketRequest { - readonly url: string - readonly headers: Headers.Headers -} -export interface WebSocketConnection { - readonly sendText: (message: string) => Effect.Effect - readonly messages: Stream.Stream - readonly close: Effect.Effect -} -export interface WebSocketExecutor { - readonly open: (request: WebSocketRequest) => Effect.Effect, E> -} -export interface WebSocketRecordReplayOptions { - readonly name: string - readonly mode?: RecordReplayMode - readonly metadata?: CassetteMetadata - readonly cassette: Interface - readonly live: WebSocketExecutor - readonly redactor?: Redactor - readonly compareClientMessagesAsJson?: boolean -} - -const headersRecord = (headers: Headers.Headers): Record => - Object.fromEntries( - Object.entries(headers as Record).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ) -const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({ direction, kind: "text", body }) -const decodeEvent = (event: WebSocketEvent) => - event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) -const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson }) -const assertClientEvent = (actual: string, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => - Effect.sync(() => { - const matches = - expected?.direction === "client" && - expected.kind === "text" && - JSON.stringify(asJson ? jsonOrText(actual) : actual) === - JSON.stringify(asJson ? jsonOrText(expected.body) : expected.body) - if (matches) return - throw new Error(`WebSocket client frame ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) - }) - -export const makeWebSocketExecutor = ( - options: WebSocketRecordReplayOptions, -): Effect.Effect, never, Scope.Scope> => - Effect.gen(function* () { - const requested = options.mode ?? "auto" - const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested - const redactor = options.redactor ?? make() - const openSnapshot = (request: WebSocketRequest) => { - const snapshot = redactor.request({ - method: "GET", - url: request.url, - headers: headersRecord(request.headers), - body: "", - }) - return { url: snapshot.url, headers: snapshot.headers } - } - const redactEvent = (event: WebSocketEvent) => { - if (event.kind === "binary") return event - const body = - event.direction === "client" - ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body - : redactor.response({ status: 101, headers: {}, body: event.body }).body - return { ...event, body } - } - if (mode === "passthrough") return options.live - if (mode === "record") { - return { - open: (request) => - Effect.gen(function* () { - const events: WebSocketEvent[] = [] - const connection = yield* options.live.open(request) - const closed = yield* Ref.make(false) - const closeLock = yield* Semaphore.make(1) - return { - sendText: (message) => - Effect.sync(() => events.push(redactEvent(textEvent("client", message)))).pipe( - Effect.andThen(connection.sendText(message)), - ), - messages: connection.messages.pipe( - Stream.tap((message) => - Effect.sync(() => - events.push( - typeof message === "string" - ? redactEvent(textEvent("server", message)) - : { - direction: "server", - kind: "binary", - body: Buffer.from(message).toString("base64"), - bodyEncoding: "base64", - }, - ), - ), - ), - ), - close: closeLock.withPermit( - Effect.gen(function* () { - if (yield* Ref.get(closed)) return - yield* connection.close - yield* options.cassette - .append( - options.name, - { transport: "websocket", open: openSnapshot(request), events }, - options.metadata, - ) - .pipe(Effect.orDie) - yield* Ref.set(closed, true) - }), - ), - } - }), - } - } - const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions) - return { - open: (request) => - Effect.gen(function* () { - const incoming = openSnapshot(request) - const claimed = yield* replay - .claim((interaction, index) => - Effect.sync(() => { - if ( - interaction?.open && - JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open)) - ) - return - throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`) - }), - ) - .pipe(Effect.orDie) - const client = claimed.interaction.events.filter((event) => event.direction === "client") - const server = claimed.interaction.events.filter((event) => event.direction === "server") - const position = yield* SynchronizedRef.make(0) - return { - sendText: (message) => - SynchronizedRef.updateEffect(position, (index) => - assertClientEvent(message, client[index], index, options.compareClientMessagesAsJson === true).pipe( - Effect.as(index + 1), - ), - ), - messages: Stream.fromIterable(server).pipe(Stream.map(decodeEvent)), - close: Effect.gen(function* () { - const used = yield* SynchronizedRef.get(position) - if (used !== client.length) - return yield* Effect.die( - new Error(`WebSocket client frame count: expected ${client.length}, received ${used}`), - ) - }), - } - }), - } - }) diff --git a/packages/http-recorder/src/websocket/model.ts b/packages/http-recorder/src/websocket/model.ts index c6ffdde2ed75..b5930141bb06 100644 --- a/packages/http-recorder/src/websocket/model.ts +++ b/packages/http-recorder/src/websocket/model.ts @@ -29,12 +29,6 @@ export const WebSocketInteractionSchema = Schema.Struct({ }), }), ), - open: Schema.optional( - Schema.Struct({ - url: Schema.String, - headers: Schema.Record(Schema.String, Schema.String), - }), - ), events: Schema.Array(WebSocketEventSchema), }) diff --git a/packages/http-recorder/src/websocket/recorder.ts b/packages/http-recorder/src/websocket/recorder.ts index ade1a70835a0..3d232c4db95b 100644 --- a/packages/http-recorder/src/websocket/recorder.ts +++ b/packages/http-recorder/src/websocket/recorder.ts @@ -9,9 +9,8 @@ import { makeReplayState, resolveAutoMode } from "../replay/state.js" import { webSocketInteractions, type Interaction } from "../cassette/model.js" import type { WebSocketEvent, WebSocketInteraction } from "./model.js" -export interface WebSocketRecorderOptions extends SocketRecorderOptions { +interface WebSocketRecorderOptions extends SocketRecorderOptions { readonly compareClientMessagesAsJson?: boolean - readonly open?: { readonly url: string; readonly headers: Record } } interface ActiveReplay { readonly interaction: WebSocketInteraction @@ -216,20 +215,11 @@ const makeRecordingSocket = ( yield* Ref.set(state.accepting, false) yield* Ref.set(active, undefined) if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return - const open = options.open - ? redactor.request({ - method: "GET", - url: options.open.url, - headers: options.open.headers, - body: "", - }) - : undefined yield* cassette .append( name, { transport: "websocket", - open: open ? { url: open.url, headers: open.headers } : undefined, events: [...state.events], }, options.metadata, @@ -270,27 +260,14 @@ const makeReplaySocket = ( const replay = yield* makeReplayState(cassette, name, webSocketInteractions) const active = yield* Ref.make(undefined) const runLock = yield* Semaphore.make(1) - const expectedOpen = options.open - ? redactor.request({ method: "GET", url: options.open.url, headers: options.open.headers, body: "" }) - : undefined return Socket.make({ runRaw: (handler, runOptions) => runLock .withPermitsIfAvailable(1)( Effect.gen(function* () { const claimed = yield* replay - .claim((interaction, index) => - Effect.sync(() => { - if (!interaction) throw new Error("Missing recorded WebSocket interaction") - if (!expectedOpen) return - const incoming = { url: expectedOpen.url, headers: expectedOpen.headers } - if ( - interaction.open && - JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open)) - ) - return - throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`) - }), + .claim((interaction) => + interaction ? Effect.void : Effect.die("Missing recorded WebSocket interaction"), ) .pipe(Effect.orDie) const state = { diff --git a/packages/http-recorder/test/cassette.test.ts b/packages/http-recorder/test/cassette.test.ts index 970e65b10c31..d793c82fe231 100644 --- a/packages/http-recorder/test/cassette.test.ts +++ b/packages/http-recorder/test/cassette.test.ts @@ -1,19 +1,174 @@ -import { expect, test } from "bun:test" -import { Effect } from "effect" -import { memory, Service } from "../src/cassette/store" - -test("memory cassettes append and replay interactions", async () => { - const interaction = { - transport: "http" as const, - request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "ok" }, - } - const stored = await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* Service - yield* cassette.append("example", interaction) - return yield* cassette.read("example") - }).pipe(Effect.provide(memory())), - ) - expect(stored).toEqual([interaction]) +import { describe, expect, test } from "bun:test" +import { Effect, Exit } from "effect" +import { existsSync, readdirSync, writeFileSync } from "node:fs" +import type { Interaction } from "../src/cassette/model" +import { Service, hasCassetteSync, memory } from "../src/cassette/store" +import { cassetteLayer } from "../src/http/recorder" +import { failureText, post, readCassette, runFileCassette, seedCassetteDirectory, tempDirectory } from "./support" + +describe("cassette", () => { + test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => { + using server = Bun.serve({ + port: 0, + fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234"), + }) + const url = `http://127.0.0.1:${server.port}/leaky` + using directory = tempDirectory("http-recorder-unsafe-") + + const exit = await Effect.runPromise( + Effect.exit( + post(url, { ok: true }).pipe( + Effect.provide( + cassetteLayer("unsafe-record", { + directory: directory.path, + mode: "record", + }), + ), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("contains possible secrets") + expect(existsSync(`${directory.path}/unsafe-record.json`)).toBe(false) + }) + + test("failed memory appends leave cassette state unchanged", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const cassette = yield* Service + const interaction: Interaction = { + transport: "http", + request: { + method: "GET", + url: "https://example.test", + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: "safe" }, + } + yield* cassette.append("transactional", interaction) + yield* cassette + .append("transactional", { + ...interaction, + response: { + ...interaction.response, + body: "Bearer abcdefghijklmnopqrstuvwxyz1234", + }, + }) + .pipe(Effect.flip) + + expect(yield* cassette.read("transactional")).toEqual([interaction]) + }).pipe(Effect.provide(memory())), + ) + }) + + test("concurrent file appends preserve every interaction", async () => { + using directory = tempDirectory("http-recorder-concurrent-") + await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + yield* Effect.forEach( + Array.from({ length: 20 }, (_, index) => index), + (index) => + cassette.append("concurrent", { + transport: "http", + request: { + method: "GET", + url: `https://example.test/${index}`, + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: String(index) }, + }), + { concurrency: "unbounded" }, + ) + }), + ) + + const cassette = readCassette(`${directory.path}/concurrent.json`) + expect(cassette.interactions).toHaveLength(20) + expect(readdirSync(directory.path).filter((file) => file.endsWith(".tmp"))).toEqual([]) + }) + + test("generated metadata cannot be overridden", async () => { + using directory = tempDirectory("http-recorder-metadata-") + await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + yield* cassette.append( + "metadata", + { + transport: "http", + request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, + response: { status: 200, headers: {}, body: "safe" }, + }, + { name: "wrong", recordedAt: "wrong" }, + ) + }), + ) + + const cassette = readCassette(`${directory.path}/metadata.json`) + expect(cassette.metadata?.name).toBe("metadata") + expect(cassette.metadata?.recordedAt).not.toBe("wrong") + }) + + test("reports malformed cassettes as invalid", async () => { + using directory = tempDirectory("http-recorder-invalid-") + writeFileSync(`${directory.path}/invalid.json`, "{not-json") + + const error = await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + return yield* cassette.read("invalid").pipe(Effect.flip) + }), + ) + + expect(error._tag).toBe("InvalidCassetteError") + }) + + test("rejects cassette paths outside the recordings directory", () => { + using directory = tempDirectory("http-recorder-path-") + expect(() => hasCassetteSync("../outside", { directory: directory.path })).toThrow("Invalid cassette name") + expect(() => hasCassetteSync("C:\\outside", { directory: directory.path })).toThrow("Invalid cassette name") + }) + + test("Cassette.list enumerates recorded cassette names", async () => { + using directory = tempDirectory("http-recorder-list-") + await seedCassetteDirectory(directory.path, "alpha/one", [ + { + transport: "http", + request: { + method: "GET", + url: "https://x.test/a", + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: "a" }, + }, + ]) + await seedCassetteDirectory(directory.path, "beta", [ + { + transport: "http", + request: { + method: "GET", + url: "https://x.test/b", + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: "b" }, + }, + ]) + + const names = await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + return yield* cassette.list() + }), + ) + expect(names).toEqual(["alpha/one", "beta"]) + }) }) diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json b/packages/http-recorder/test/fixtures/recordings/http/multi-step.json similarity index 100% rename from packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json rename to packages/http-recorder/test/fixtures/recordings/http/multi-step.json diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json b/packages/http-recorder/test/fixtures/recordings/http/retry.json similarity index 100% rename from packages/http-recorder/test/fixtures/recordings/record-replay/retry.json rename to packages/http-recorder/test/fixtures/recordings/http/retry.json diff --git a/packages/http-recorder/test/http.test.ts b/packages/http-recorder/test/http.test.ts index 8a2f6a36cf82..ebba965c7df1 100644 --- a/packages/http-recorder/test/http.test.ts +++ b/packages/http-recorder/test/http.test.ts @@ -1,12 +1,328 @@ -import { expect, test } from "bun:test" +import { describe, expect, test } from "bun:test" +import { Effect, Exit } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { existsSync } from "node:fs" +import { isHttpInteraction } from "../src/cassette/model" import { HttpRecorder } from "../src" +import { failureText, post, readCassette, seedCassetteDirectory, tempDirectory, withEnvironment } from "./support" -test("public API exposes the upstream 0.3 layer contract with the V1 compatibility alias", () => { - expect(Object.keys(HttpRecorder).sort()).toEqual([ - "http", - "layer", - "layerFetch", - "layerSocket", - "layerWebSocketConstructor", - ]) +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch("http/multi-step")))) + +const runWith = ( + name: string, + options: HttpRecorder.RecorderOptions, + effect: Effect.Effect, +) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch(name, options)))) + +describe("HTTP", () => { + test("decorates a provided HTTP client", async () => { + await Effect.runPromise( + Effect.all([post("https://example.test/echo", { step: 1 }), post("https://example.test/echo", { step: 2 })]).pipe( + Effect.provide(HttpRecorder.layer("http/multi-step")), + Effect.provide(FetchHttpClient.layer), + ), + ) + }) + + test("replay returns recorded responses in order for identical requests", async () => { + await runWith( + "http/retry", + {}, + Effect.gen(function* () { + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') + }), + ) + }) + + test("replay reports exhaustion when more requests are made than recorded", async () => { + await run( + Effect.gen(function* () { + yield* post("https://example.test/echo", { step: 1 }) + yield* post("https://example.test/echo", { step: 2 }) + const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + }) + + test("a mismatch does not consume an interaction", async () => { + await run( + Effect.gen(function* () { + yield* post("https://example.test/echo", { step: 1 }) + const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("$.step expected 2, received 3") + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + }), + ) + }) + + test("distinct requests replay in any order", async () => { + await run( + Effect.gen(function* () { + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}') + }), + ) + }) + + test("concurrent distinct requests atomically claim their matching interactions", async () => { + const results = await run( + Effect.all([post("https://example.test/echo", { step: 2 }), post("https://example.test/echo", { step: 1 })], { + concurrency: "unbounded", + }), + ) + + expect(results).toEqual(['{"reply":"second"}', '{"reply":"first"}']) + }) + + test("concurrent replay claims each interaction once", async () => { + const results = await runWith( + "http/retry", + {}, + Effect.all( + [post("https://example.test/poll", { id: "job_1" }), post("https://example.test/poll", { id: "job_1" })], + { concurrency: "unbounded" }, + ), + ) + + expect(results.toSorted()).toEqual(['{"status":"complete"}', '{"status":"pending"}']) + }) + + test("mismatch diagnostics show redacted request differences against the expected interaction", async () => { + await run( + Effect.gen(function* () { + const exit = yield* Effect.exit( + post("https://example.test/echo?api_key=secret-value", { + step: 3, + token: "sk-123456789012345678901234", + }), + ) + const message = failureText(exit) + expect(message).toContain("url:") + expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") + expect(message).toContain("body:") + expect(message).toContain("$.step expected 1, received 3") + expect(message).toContain('$.token expected undefined, received "[REDACTED]"') + expect(message).not.toContain("sk-123456789012345678901234") + }), + ) + }) + + test("applies custom URL redaction to mismatch errors", async () => { + const secret = "private-account" + const exit = await Effect.runPromiseExit( + post(`https://example.test/${secret}`, { step: 1 }).pipe( + Effect.provide( + HttpRecorder.layerFetch("http/multi-step", { + redact: { url: (url) => url.replace(secret, "{account}") }, + }), + ), + ), + ) + const message = failureText(exit) + + expect(message).toContain("https://example.test/{account}") + expect(message).not.toContain(secret) + }) + + test("fails when a non-empty replay cassette is completely unused", async () => { + const exit = await Effect.runPromiseExit( + Effect.void.pipe(Effect.scoped, Effect.provide(HttpRecorder.layerFetch("http/multi-step"))), + ) + + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("Unused recorded interactions in http/multi-step: used 0 of 2") + }) + + test("allows an unused replay layer when the cassette is missing", async () => { + using directory = tempDirectory("http-recorder-unused-missing-") + await withEnvironment("CI", "true", () => + Effect.runPromise( + Effect.void.pipe( + Effect.scoped, + Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory: directory.path })), + ), + ), + ) + }) + + describe("auto mode", () => { + test("replays when the cassette exists", async () => { + using directory = tempDirectory("http-recorder-auto-") + await seedCassetteDirectory(directory.path, "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", + { directory: directory.path }, + post("https://example.test/echo", { step: 1 }), + ) + expect(result).toBe('{"reply":"hi"}') + }) + + test("forces replay when CI=true even if cassette is missing", async () => { + using directory = tempDirectory("http-recorder-auto-ci-") + await withEnvironment("CI", "true", async () => { + const exit = await Effect.runPromise( + Effect.exit( + post("https://example.test/echo", { step: 1 }).pipe( + Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory: directory.path })), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain('Fixture "missing-cassette" not found') + }) + }) + + test("records to disk when the cassette is missing", async () => { + using directory = tempDirectory("http-recorder-auto-record-") + using server = Bun.serve({ + port: 0, + fetch: () => + new Response('{"reply":"recorded"}', { + headers: { "content-type": "application/json" }, + }), + }) + const url = `http://127.0.0.1:${server.port}/echo` + await withEnvironment("CI", undefined, async () => { + const result = await runWith("auto-record", { directory: directory.path }, post(url, { step: 1 })) + expect(result).toBe('{"reply":"recorded"}') + expect(existsSync(`${directory.path}/auto-record.json`)).toBe(true) + }) + }) + + test("records concurrent requests in request-start order", async () => { + using directory = tempDirectory("http-recorder-order-") + const first = Promise.withResolvers() + const completed: string[] = [] + using server = Bun.serve({ + port: 0, + fetch: async (request) => { + const name = new URL(request.url).pathname.slice(1) + if (name === "first") { + await first.promise + completed.push(name) + return new Response(name) + } + completed.push(name) + first.resolve() + return new Response(name) + }, + }) + await withEnvironment("CI", undefined, async () => { + const request = (name: string) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/${name}`)) + return yield* response.text + }) + const responses = await Effect.runPromise( + Effect.all([request("first"), request("second")], { + concurrency: "unbounded", + }).pipe(Effect.provide(HttpRecorder.layerFetch("concurrent-order", { directory: directory.path }))), + ) + const cassette = readCassette(`${directory.path}/concurrent-order.json`) + + expect(completed).toEqual(["second", "first"]) + expect(responses).toEqual(["first", "second"]) + expect(cassette.interactions.filter(isHttpInteraction).map((interaction) => interaction.request.url)).toEqual([ + `http://127.0.0.1:${server.port}/first`, + `http://127.0.0.1:${server.port}/second`, + ]) + }) + }) + + test("returns the live response while persisting its redacted snapshot", async () => { + using directory = tempDirectory("http-recorder-live-response-") + using server = Bun.serve({ + port: 0, + fetch: () => + new Response(JSON.stringify({ access_token: "live-secret", safe: true }), { + headers: { + "content-type": "application/json", + "x-request-id": "request-1", + }, + }), + }) + await withEnvironment("CI", undefined, async () => { + const body = await runWith( + "live-response", + { directory: directory.path }, + post(`http://127.0.0.1:${server.port}/response`, { ok: true }), + ) + const cassette = readCassette(`${directory.path}/live-response.json`) + const interaction = cassette.interactions.find(isHttpInteraction) + + expect(body).toBe('{"access_token":"live-secret","safe":true}') + expect(interaction?.response.body).toBe('{"access_token":"[REDACTED]","safe":true}') + }) + }) + + test("reconstructs responses with null-body statuses", async () => { + using directory = tempDirectory("http-recorder-no-content-") + using server = Bun.serve({ + port: 0, + fetch: () => new Response(null, { status: 204 }), + }) + await withEnvironment("CI", undefined, async () => { + const program = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + return yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/empty`)) + }) + const response = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("no-content", { directory: directory.path }))), + ) + + expect(response.status).toBe(204) + }) + }) + + test("records and replays arbitrary binary responses without changing bytes", async () => { + using directory = tempDirectory("http-recorder-binary-") + const expected = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00, 0x80]) + using server = Bun.serve({ + port: 0, + fetch: () => new Response(expected, { headers: { "content-type": "image/png" } }), + }) + const url = `http://127.0.0.1:${server.port}/image.png` + await withEnvironment("CI", undefined, async () => { + const program = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute(HttpClientRequest.get(url)) + return new Uint8Array(yield* response.arrayBuffer) + }) + const record = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory: directory.path }))), + ) + await server.stop() + const replay = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory: directory.path }))), + ) + const cassette = readCassette(`${directory.path}/binary.json`) + const interaction = cassette.interactions.find(isHttpInteraction) + + expect(record).toEqual(expected) + expect(replay).toEqual(expected) + expect(interaction?.response.bodyEncoding).toBe("base64") + }) + }) + }) }) diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts deleted file mode 100644 index aec7b272fa86..000000000000 --- a/packages/http-recorder/test/record-replay.test.ts +++ /dev/null @@ -1,932 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { describe, expect, test } from "bun:test" -import { Cause, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" -import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" -import { Socket } from "effect/unstable/socket" -import fs from "node:fs" -import os from "node:os" -import path from "node:path" -import { HttpRecorder } from "../src" -import { HttpRecorderInternal } from "../src/internal" -import type { Interaction } from "../src/cassette/model" -import { redactedErrorRequest } from "../src/http/recorder" - -const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => - Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) - }).pipe( - Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), - ) - -const post = (url: string, body: object) => - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const request = HttpClientRequest.post(url, { - headers: { "content-type": "application/json" }, - body: HttpBody.text(JSON.stringify(body), "application/json"), - }) - const response = yield* http.execute(request) - return yield* response.text - }) - -const run = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch("record-replay/multi-step")))) - -const runWith = ( - name: string, - options: HttpRecorder.RecorderOptions, - effect: Effect.Effect, -) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch(name, options)))) - -const runRecorder = (effect: Effect.Effect) => - Effect.runPromise( - Effect.scoped( - effect.pipe( - Effect.provide( - HttpRecorderInternal.Cassette.fileSystem({ - directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")), - }), - ), - Effect.provide(NodeFileSystem.layer), - ), - ), - ) - -const failureText = (exit: Exit.Exit) => { - if (Exit.isSuccess(exit)) return "" - return Cause.prettyErrors(exit.cause).join("\n") -} - -describe("http-recorder", () => { - test("redacts sensitive URL query parameters", () => { - expect( - HttpRecorderInternal.redactUrl( - "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature", - ), - ).toBe( - "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D", - ) - }) - - test("redacts URL credentials", () => { - expect(HttpRecorderInternal.redactUrl("https://user:password@example.test/path?safe=value")).toBe( - "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value", - ) - }) - - test("applies custom URL redaction after built-in redaction", () => { - expect( - HttpRecorderInternal.redactUrl( - "https://example.test/accounts/real-account/path?key=secret-key", - undefined, - (url) => url.replace("/accounts/real-account/", "/accounts/{account}/"), - ), - ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D") - }) - - test("redacts sensitive headers when allow-listed", () => { - expect( - HttpRecorderInternal.redactHeaders( - { - authorization: "Bearer secret-token", - "content-type": "application/json", - "x-custom-token": "custom-secret", - "x-api-key": "secret-key", - "x-goog-api-key": "secret-google-key", - }, - ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"], - ["x-custom-token"], - ), - ).toEqual({ - authorization: "[REDACTED]", - "content-type": "application/json", - "x-api-key": "[REDACTED]", - "x-custom-token": "[REDACTED]", - "x-goog-api-key": "[REDACTED]", - }) - }) - - test("redacts error requests without retaining headers, params, or body", () => { - const request = HttpClientRequest.post("https://example.test/path", { - headers: { authorization: "Bearer super-secret" }, - body: HttpBody.text("super-secret-body", "text/plain"), - }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key")) - - expect(redactedErrorRequest(request).toJSON()).toMatchObject({ - url: "https://example.test/path", - urlParams: { params: [] }, - headers: {}, - body: { _tag: "Empty" }, - }) - }) - - test("detects secret-looking values without returning the secret", () => { - expect( - HttpRecorderInternal.secretFindings({ - version: 1, - interactions: [ - { - transport: "http", - request: { - method: "POST", - url: "https://example.test/path?key=sk-123456789012345678901234", - headers: {}, - body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }), - }, - response: { - status: 200, - headers: {}, - body: "Bearer abcdefghijklmnopqrstuvwxyz", - }, - }, - ], - }), - ).toEqual([ - { path: "interactions[0].request.url", reason: "API key" }, - { path: "interactions[0].request.body", reason: "Google API key" }, - { path: "interactions[0].response.body", reason: "bearer token" }, - ]) - }) - - test("detects secret-looking values inside metadata", () => { - expect( - HttpRecorderInternal.secretFindings({ - version: 1, - metadata: { token: "sk-123456789012345678901234" }, - interactions: [], - }), - ).toEqual([{ path: "metadata.token", reason: "API key" }]) - }) - - test("redacts configured and common sensitive JSON fields", () => { - const redactor = HttpRecorderInternal.Redactor.make({ jsonFields: ["account_id"] }) - const request = redactor.request({ - method: "POST", - url: "https://example.test/path", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - password: "secret-password", - accessToken: "access-token", - nested: { account_id: "account-123", safe: "visible" }, - }), - }) - - expect(JSON.parse(request.body)).toEqual({ - password: "[REDACTED]", - accessToken: "[REDACTED]", - nested: { account_id: "[REDACTED]", safe: "visible" }, - }) - }) - - test("extends default header redaction and allow lists", () => { - const redactor = HttpRecorderInternal.Redactor.make({ - headers: ["x-custom-token"], - allowRequestHeaders: ["anthropic-version", "x-custom-token"], - }) - - expect( - redactor.request({ - method: "GET", - url: "https://example.test/path", - headers: { - authorization: "Bearer secret", - "content-type": "application/json", - "anthropic-version": "2023-06-01", - "x-custom-token": "secret", - }, - body: "", - }).headers, - ).toEqual({ - "anthropic-version": "2023-06-01", - "content-type": "application/json", - "x-custom-token": "[REDACTED]", - }) - }) - - test("records WebSocket frames in observed client/server order", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - const response = JSON.stringify({ type: "response.completed", token: "server-secret" }) - let receive: ((message: string | Uint8Array) => Effect.Effect | void) | undefined - const upstream = Socket.make({ - runRaw: (handler, options) => - Effect.gen(function* () { - receive = handler - if (options?.onOpen) yield* options.onOpen - receive = undefined - }), - writer: Effect.succeed(() => - Effect.suspend(() => { - const result = receive?.(response) - return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void - }), - ), - }) - - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - yield* socket.runRaw(() => {}, { - onOpen: write(JSON.stringify({ type: "response.create", token: "client-secret" })), - }) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/record", - { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - { directory, metadata: { provider: "test" }, mode: "record" }, - ).pipe(Layer.provide(Layer.succeed(Socket.Socket, upstream))), - ), - ), - ) - - expect(JSON.parse(fs.readFileSync(path.join(directory, "websocket/record.json"), "utf8"))).toMatchObject({ - interactions: [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - events: [ - { direction: "client", kind: "text", body: '{"type":"response.create","token":"[REDACTED]"}' }, - { direction: "server", kind: "text", body: '{"type":"response.completed","token":"[REDACTED]"}' }, - ], - }, - ], - }) - }) - - test("WebSocket replay preserves causal frame ordering", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/replay", [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: {} }, - events: [ - { direction: "server", kind: "text", body: '{"type":"session.created"}' }, - { direction: "client", kind: "text", body: '{"type":"response.create","prompt":"hello"}' }, - { direction: "server", kind: "text", body: '{"type":"response.completed"}' }, - ], - }, - ]) - - const received: string[] = [] - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - yield* socket.runRaw((message) => { - if (typeof message !== "string") return - received.push(message) - if (JSON.parse(message).type === "session.created") - return write('{"prompt":"hello","type":"response.create"}') - }) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/replay", - { url: "wss://example.test/realtime" }, - { directory, compareClientMessagesAsJson: true, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(received).toEqual(['{"type":"session.created"}', '{"type":"response.completed"}']) - }) - - test("WebSocket replay validates the private socket connection", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/open-mismatch", [ - { - transport: "websocket", - open: { url: "wss://example.test/expected", headers: {} }, - events: [], - }, - ]) - - const exit = await Effect.runPromiseExit( - Effect.gen(function* () { - const socket = yield* Socket.Socket - yield* socket.runRaw(() => {}) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/open-mismatch", - { url: "wss://example.test/received" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(failureText(exit)).toContain("WebSocket open 1 does not match") - expect(failureText(exit)).toContain("wss://example.test/received") - }) - - test("the public socket decorator replays a provided Effect socket", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/public-layer", [ - { - transport: "websocket", - open: { url: "", headers: {} }, - events: [ - { direction: "client", kind: "text", body: "hello" }, - { direction: "server", kind: "text", body: "hello" }, - ], - }, - ]) - - const received: string[] = [] - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - yield* socket.runString( - (message) => - Effect.gen(function* () { - received.push(message) - yield* write(new Socket.CloseEvent(1000)) - }), - { onOpen: write("hello") }, - ) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorder.layerSocket("websocket/public-layer", { directory }).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(received).toEqual(["hello"]) - }) - - test("WebSocket replay runs message handlers concurrently", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/concurrent-handlers", [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: {} }, - events: [ - { direction: "server", kind: "text", body: "first" }, - { direction: "server", kind: "text", body: "second" }, - ], - }, - ]) - - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const second = yield* Deferred.make() - yield* socket.runString((message) => - message === "first" ? Deferred.await(second) : Deferred.succeed(second, undefined), - ) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/concurrent-handlers", - { url: "wss://example.test/realtime" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - }) - - test("WebSocket replay rejects close with unconsumed events", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/early-close", [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: {} }, - events: [{ direction: "client", kind: "text", body: "expected" }], - }, - ]) - - const exit = await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - return yield* Effect.exit(socket.runRaw(() => {}, { onOpen: write(new Socket.CloseEvent(1000)) })) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/early-close", - { url: "wss://example.test/realtime" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(failureText(exit)).toContain("closed with unconsumed events") - }) - - test("failed WebSocket runs do not write complete cassettes", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - const exit = await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - return yield* Effect.exit(socket.runRaw(() => {})) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/failed-run", - { url: "wss://example.test/realtime" }, - { directory, mode: "record" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("connection failed")), - writer: Effect.succeed(() => Effect.void), - }), - ), - ), - ), - ), - ), - ) - - expect(Exit.isFailure(exit)).toBe(true) - expect(fs.existsSync(path.join(directory, "websocket/failed-run.json"))).toBe(false) - }) - - test("WebSocket replay preserves binary frame kinds across reconnects", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - const interaction = { - transport: "websocket" as const, - open: { url: "wss://example.test/binary", headers: {} }, - events: [ - { - direction: "client" as const, - kind: "binary" as const, - body: Buffer.from([1, 2]).toString("base64"), - bodyEncoding: "base64" as const, - }, - { - direction: "server" as const, - kind: "binary" as const, - body: Buffer.from([3, 4]).toString("base64"), - bodyEncoding: "base64" as const, - }, - ], - } - await seedCassetteDirectory(directory, "websocket/binary", [interaction, interaction]) - - const received: number[][] = [] - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - const run = socket.runRaw( - (message) => { - if (typeof message === "string") throw new Error("Expected a binary WebSocket frame") - received.push([...message]) - }, - { onOpen: write(new Uint8Array([1, 2])) }, - ) - yield* run - yield* run - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/binary", - { url: "wss://example.test/binary" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(received).toEqual([ - [3, 4], - [3, 4], - ]) - }) - - test("replay returns recorded responses in order for identical requests", async () => { - await runWith( - "record-replay/retry", - {}, - Effect.gen(function* () { - expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') - expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') - }), - ) - }) - - test("replay reports cursor exhaustion when more requests are made than recorded", async () => { - await run( - Effect.gen(function* () { - yield* post("https://example.test/echo", { step: 1 }) - yield* post("https://example.test/echo", { step: 2 }) - const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) - expect(Exit.isFailure(exit)).toBe(true) - }), - ) - }) - - test("replay validates each recorded request in order", async () => { - await run( - Effect.gen(function* () { - yield* post("https://example.test/echo", { step: 1 }) - const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) - expect(Exit.isFailure(exit)).toBe(true) - expect(failureText(exit)).toContain("$.step expected 2, received 3") - expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') - }), - ) - }) - - test("distinct requests replay in any order", async () => { - await run( - Effect.gen(function* () { - expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') - expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}') - }), - ) - }) - - test("concurrent replay claims each interaction once", async () => { - const results = await runWith( - "record-replay/retry", - {}, - Effect.all( - [post("https://example.test/poll", { id: "job_1" }), post("https://example.test/poll", { id: "job_1" })], - { concurrency: "unbounded" }, - ), - ) - - expect(results.toSorted()).toEqual(['{"status":"complete"}', '{"status":"pending"}']) - }) - - test("replays when the cassette exists", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-")) - 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", { directory }, post("https://example.test/echo", { step: 1 })) - expect(result).toBe('{"reply":"hi"}') - }) - - test("forces replay when CI=true even if cassette is missing", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-")) - const previous = process.env.CI - process.env.CI = "true" - try { - const exit = await Effect.runPromise( - Effect.exit( - post("https://example.test/echo", { step: 1 }).pipe( - Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory })), - ), - ), - ) - expect(Exit.isFailure(exit)).toBe(true) - expect(failureText(exit)).toContain('Fixture "missing-cassette" not found') - } finally { - if (previous === undefined) delete process.env.CI - else process.env.CI = previous - } - }) - - test("mismatch diagnostics show redacted request differences against the expected interaction", async () => { - await run( - Effect.gen(function* () { - const exit = yield* Effect.exit( - post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }), - ) - const message = failureText(exit) - expect(message).toContain("url:") - expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") - expect(message).toContain("body:") - expect(message).toContain("$.step expected 1, received 3") - expect(message).toContain('$.token expected undefined, received "[REDACTED]"') - expect(message).not.toContain("sk-123456789012345678901234") - }), - ) - }) - - test("records to disk when the cassette is missing", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-record-")) - using server = Bun.serve({ - port: 0, - fetch: () => new Response('{"reply":"recorded"}', { headers: { "content-type": "application/json" } }), - }) - const url = `http://127.0.0.1:${server.port}/echo` - // CI=true forces replay; clear it so we exercise the local-dev auto-record path. - const previous = process.env.CI - delete process.env.CI - try { - const result = await runWith("auto-record", { directory }, post(url, { step: 1 })) - expect(result).toBe('{"reply":"recorded"}') - expect(fs.existsSync(path.join(directory, "auto-record.json"))).toBe(true) - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("records concurrent requests in request-start order", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-order-")) - const first = Promise.withResolvers() - const completed: string[] = [] - using server = Bun.serve({ - port: 0, - fetch: async (request) => { - const name = new URL(request.url).pathname.slice(1) - if (name === "first") { - await first.promise - completed.push(name) - return new Response(name) - } - completed.push(name) - first.resolve() - return new Response(name) - }, - }) - const previous = process.env.CI - delete process.env.CI - try { - const request = (name: string) => - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const response = yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/${name}`)) - return yield* response.text - }) - const responses = await Effect.runPromise( - Effect.all([request("first"), request("second")], { concurrency: "unbounded" }).pipe( - Effect.provide(HttpRecorder.layerFetch("concurrent-order", { directory })), - ), - ) - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "concurrent-order.json"), "utf8")) - - expect(completed).toEqual(["second", "first"]) - expect(responses).toEqual(["first", "second"]) - expect(cassette.interactions.map((interaction: Interaction) => interaction.request.url)).toEqual([ - `http://127.0.0.1:${server.port}/first`, - `http://127.0.0.1:${server.port}/second`, - ]) - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("returns the live response while persisting its redacted snapshot", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-live-response-")) - using server = Bun.serve({ - port: 0, - fetch: () => - new Response(JSON.stringify({ access_token: "live-secret", safe: true }), { - headers: { "content-type": "application/json", "x-request-id": "request-1" }, - }), - }) - const previous = process.env.CI - delete process.env.CI - try { - const body = await runWith( - "live-response", - { directory }, - post(`http://127.0.0.1:${server.port}/response`, { ok: true }), - ) - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "live-response.json"), "utf8")) - - expect(body).toBe('{"access_token":"live-secret","safe":true}') - expect(cassette.interactions[0].response.body).toBe('{"access_token":"[REDACTED]","safe":true}') - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("reconstructs responses with null-body statuses", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-no-content-")) - using server = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 204 }) }) - const previous = process.env.CI - delete process.env.CI - try { - const program = Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - return yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/empty`)) - }) - const response = await Effect.runPromise( - program.pipe(Effect.provide(HttpRecorder.layerFetch("no-content", { directory }))), - ) - - expect(response.status).toBe(204) - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("records and replays arbitrary binary responses without changing bytes", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-binary-")) - const expected = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00, 0x80]) - using server = Bun.serve({ - port: 0, - fetch: () => new Response(expected, { headers: { "content-type": "image/png" } }), - }) - const url = `http://127.0.0.1:${server.port}/image.png` - const previous = process.env.CI - delete process.env.CI - try { - const program = Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const response = yield* http.execute(HttpClientRequest.get(url)) - return new Uint8Array(yield* response.arrayBuffer) - }) - const record = await Effect.runPromise( - program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory }))), - ) - await server.stop() - const replay = await Effect.runPromise( - program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory }))), - ) - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "binary.json"), "utf8")) - - expect(record).toEqual(expected) - expect(replay).toEqual(expected) - expect(cassette.interactions[0].response.bodyEncoding).toBe("base64") - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => { - using server = Bun.serve({ port: 0, fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234") }) - const url = `http://127.0.0.1:${server.port}/leaky` - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-unsafe-")) - - const exit = await Effect.runPromise( - Effect.exit( - post(url, { ok: true }).pipe( - Effect.provide(HttpRecorderInternal.cassetteLayer("unsafe-record", { directory, mode: "record" })), - ), - ), - ) - expect(Exit.isFailure(exit)).toBe(true) - expect(failureText(exit)).toContain("contains possible secrets") - expect(fs.existsSync(path.join(directory, "unsafe-record.json"))).toBe(false) - }) - - test("failed memory appends leave cassette state unchanged", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - const interaction: Interaction = { - transport: "http", - request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "safe" }, - } - yield* cassette.append("transactional", interaction) - yield* cassette - .append("transactional", { - ...interaction, - response: { ...interaction.response, body: "Bearer abcdefghijklmnopqrstuvwxyz1234" }, - }) - .pipe(Effect.flip) - - expect(yield* cassette.read("transactional")).toEqual([interaction]) - }).pipe(Effect.provide(HttpRecorderInternal.Cassette.memory())), - ) - }) - - test("concurrent file appends preserve every interaction", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-concurrent-")) - await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - yield* Effect.forEach( - Array.from({ length: 20 }, (_, index) => index), - (index) => - cassette.append("concurrent", { - transport: "http", - request: { method: "GET", url: `https://example.test/${index}`, headers: {}, body: "" }, - response: { status: 200, headers: {}, body: String(index) }, - }), - { concurrency: "unbounded" }, - ) - }).pipe( - Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), - ) - - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "concurrent.json"), "utf8")) - expect(cassette.interactions).toHaveLength(20) - expect(fs.readdirSync(directory).filter((file) => file.endsWith(".tmp"))).toEqual([]) - }) - - test("rejects cassette paths outside the recordings directory", () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-path-")) - expect(() => HttpRecorderInternal.hasCassetteSync("../outside", { directory })).toThrow("Invalid cassette name") - expect(() => HttpRecorderInternal.hasCassetteSync("C:\\outside", { directory })).toThrow("Invalid cassette name") - }) - - test("Cassette.list enumerates recorded cassette names", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-list-")) - await seedCassetteDirectory(directory, "alpha/one", [ - { - transport: "http", - request: { method: "GET", url: "https://x.test/a", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "a" }, - }, - ]) - await seedCassetteDirectory(directory, "beta", [ - { - transport: "http", - request: { method: "GET", url: "https://x.test/b", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "b" }, - }, - ]) - - const names = await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - return yield* cassette.list() - }).pipe( - Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), - ) - expect(names).toEqual(["alpha/one", "beta"]) - }) -}) diff --git a/packages/http-recorder/test/redaction.test.ts b/packages/http-recorder/test/redaction.test.ts index 1437f170071f..64e139b01fa5 100644 --- a/packages/http-recorder/test/redaction.test.ts +++ b/packages/http-recorder/test/redaction.test.ts @@ -1,13 +1,167 @@ -import { expect, test } from "bun:test" -import { make } from "../src/redaction/redactor" - -test("redacts common JSON secrets", () => { - const request = make().request({ - method: "POST", - url: "https://example.test", - headers: { authorization: "Bearer secret", "content-type": "application/json" }, - body: '{"access_token":"secret","safe":true}', - }) - expect(request.headers).toEqual({ "content-type": "application/json" }) - expect(request.body).toBe('{"access_token":"[REDACTED]","safe":true}') +import { describe, expect, test } from "bun:test" +import { HttpBody, HttpClientRequest } from "effect/unstable/http" +import { redactedErrorRequest } from "../src/http/recorder" +import { make, redactHeaders, redactUrl } from "../src/redaction/redactor" +import { secretFindings } from "../src/redaction/secrets" + +describe("redaction", () => { + test("redacts sensitive URL query parameters", () => { + expect( + redactUrl( + "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature", + ), + ).toBe( + "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D", + ) + }) + + test("redacts URL credentials", () => { + expect(redactUrl("https://user:password@example.test/path?safe=value")).toBe( + "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value", + ) + }) + + test("applies custom URL redaction after built-in redaction", () => { + expect( + redactUrl("https://example.test/accounts/real-account/path?key=secret-key", undefined, (url) => + url.replace("/accounts/real-account/", "/accounts/{account}/"), + ), + ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D") + }) + + test("redacts sensitive headers when allow-listed", () => { + expect( + redactHeaders( + { + authorization: "Bearer secret-token", + "content-type": "application/json", + "x-custom-token": "custom-secret", + "x-api-key": "secret-key", + "x-goog-api-key": "secret-google-key", + }, + ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"], + ["x-custom-token"], + ), + ).toEqual({ + authorization: "[REDACTED]", + "content-type": "application/json", + "x-api-key": "[REDACTED]", + "x-custom-token": "[REDACTED]", + "x-goog-api-key": "[REDACTED]", + }) + }) + + test("redacts error requests without retaining headers, params, or body", () => { + const request = HttpClientRequest.post("https://example.test/path", { + headers: { authorization: "Bearer super-secret" }, + body: HttpBody.text("super-secret-body", "text/plain"), + }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key")) + + expect(redactedErrorRequest(request).toJSON()).toMatchObject({ + url: "https://example.test/path", + urlParams: { params: [] }, + headers: {}, + body: { _tag: "Empty" }, + }) + }) + + test("detects secret-looking values without returning the secret", () => { + expect( + secretFindings({ + version: 1, + interactions: [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/path?key=sk-123456789012345678901234", + headers: {}, + body: JSON.stringify({ + nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE", + }), + }, + response: { + status: 200, + headers: {}, + body: "Bearer abcdefghijklmnopqrstuvwxyz", + }, + }, + ], + }), + ).toEqual([ + { path: "interactions[0].request.url", reason: "API key" }, + { path: "interactions[0].request.body", reason: "Google API key" }, + { path: "interactions[0].response.body", reason: "bearer token" }, + ]) + }) + + test("detects secret-looking values inside metadata", () => { + expect( + secretFindings({ + version: 1, + metadata: { token: "sk-123456789012345678901234" }, + interactions: [], + }), + ).toEqual([{ path: "metadata.token", reason: "API key" }]) + }) + + test("redacts configured and common sensitive JSON fields", () => { + const redactor = make({ + jsonFields: ["account_id"], + }) + const request = redactor.request({ + method: "POST", + url: "https://example.test/path", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + password: "secret-password", + accessToken: "access-token", + nested: { account_id: "account-123", safe: "visible" }, + }), + }) + + expect(JSON.parse(request.body)).toEqual({ + password: "[REDACTED]", + accessToken: "[REDACTED]", + nested: { account_id: "[REDACTED]", safe: "visible" }, + }) + }) + + test("preserves JSON text when no fields are redacted", () => { + const body = '{\n "id": 9007199254740993,\n "safe": true\n}' + + expect( + make().request({ + method: "POST", + url: "https://example.test/path", + headers: { "content-type": "application/json" }, + body, + }).body, + ).toBe(body) + }) + + test("extends default header redaction and allow lists", () => { + const redactor = make({ + headers: ["x-custom-token"], + allowRequestHeaders: ["anthropic-version", "x-custom-token"], + }) + + expect( + redactor.request({ + method: "GET", + url: "https://example.test/path", + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + "anthropic-version": "2023-06-01", + "x-custom-token": "secret", + }, + body: "", + }).headers, + ).toEqual({ + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "x-custom-token": "[REDACTED]", + }) + }) }) diff --git a/packages/http-recorder/test/support.ts b/packages/http-recorder/test/support.ts new file mode 100644 index 000000000000..30fa8205f220 --- /dev/null +++ b/packages/http-recorder/test/support.ts @@ -0,0 +1,61 @@ +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Cause, Effect, Exit } from "effect" +import { HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { mkdtempSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { decodeCassette, type Interaction } from "../src/cassette/model" +import { Service, fileSystem } from "../src/cassette/store" + +export const tempDirectory = (prefix: string) => { + const directory = mkdtempSync(join(tmpdir(), prefix)) + return { + path: directory, + [Symbol.dispose]() { + rmSync(directory, { recursive: true, force: true }) + }, + } +} + +export const post = (url: string, body: object) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute( + HttpClientRequest.post(url, { + headers: { "content-type": "application/json" }, + body: HttpBody.text(JSON.stringify(body), "application/json"), + }), + ) + return yield* response.text + }) + +export const readCassette = (file: string) => decodeCassette(JSON.parse(readFileSync(file, "utf8"))) + +export const runFileCassette = (directory: string, effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(fileSystem({ directory })), Effect.provide(NodeFileSystem.layer))) + +export const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => + runFileCassette( + directory, + Effect.gen(function* () { + const cassette = yield* Service + yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) + }), + ) + +export const withEnvironment = async (name: string, value: string | undefined, run: () => Promise) => { + const previous = process.env[name] + if (value === undefined) delete process.env[name] + else process.env[name] = value + try { + return await run() + } finally { + if (previous === undefined) delete process.env[name] + else process.env[name] = previous + } +} + +export const failureText = (exit: Exit.Exit) => { + if (Exit.isSuccess(exit)) return "" + return Cause.prettyErrors(exit.cause).join("\n") +} diff --git a/packages/http-recorder/test/tsconfig.json b/packages/http-recorder/test/tsconfig.json new file mode 100644 index 000000000000..97888caa286c --- /dev/null +++ b/packages/http-recorder/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "declaration": false, + "module": "preserve", + "moduleResolution": "bundler" + }, + "include": ["../src", "."] +} diff --git a/packages/http-recorder/test/websocket.test.ts b/packages/http-recorder/test/websocket.test.ts index 21dbb3ca3502..2d4d30cbf266 100644 --- a/packages/http-recorder/test/websocket.test.ts +++ b/packages/http-recorder/test/websocket.test.ts @@ -1,67 +1,529 @@ -import { expect, test } from "bun:test" -import { Effect, Layer } from "effect" +import { describe, expect, test } from "bun:test" +import { Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Socket } from "effect/unstable/socket" -import fs from "node:fs" -import os from "node:os" -import path from "node:path" +import { existsSync } from "node:fs" import { HttpRecorder } from "../src" -import { decodeCassette } from "../src/cassette/model" +import { layerSocketWithMode } from "../src/websocket/recorder" +import { failureText, readCassette, seedCassetteDirectory, tempDirectory, withEnvironment } from "./support" -test("accepts upstream WebSocket interactions and the OpenCode open snapshot extension", () => { - const cassette = decodeCassette({ - version: 1, - interactions: [ - { transport: "websocket", events: [] }, +const unavailableSocket = Socket.make({ + runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), + writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), +}) + +class EchoWebSocket extends EventTarget { + readonly protocol = "" + readonly extensions = "" + bufferedAmount = 0 + binaryType: BinaryType = "blob" + readyState = 0 + + constructor(readonly url: string) { + super() + queueMicrotask(() => { + this.readyState = 1 + this.dispatchEvent(new Event("open")) + }) + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { + queueMicrotask(() => this.dispatchEvent(new MessageEvent("message", { data }))) + } + + close(code = 1000, reason = "") { + if (this.readyState === 3) return + this.readyState = 3 + this.dispatchEvent(new CloseEvent("close", { code, reason, wasClean: code === 1000 })) + } +} + +describe("WebSocket", () => { + test("constructor recording is complete when the recorder layer closes", async () => { + using directory = tempDirectory("http-recorder-websocket-constructor-") + const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-record", { + directory: directory.path, + }).pipe( + Layer.provide( + Layer.succeed(Socket.WebSocketConstructor, (url) => new EchoWebSocket(url) as unknown as globalThis.WebSocket), + ), + ) + + await withEnvironment("CI", undefined, () => + Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://echo.example.test/one", { + protocols: ["echo.v1"], + closeCodeIsError: () => false, + }) + const write = yield* socket.writer + yield* socket.runString(() => write(new Socket.CloseEvent(1000, "complete")).pipe(Effect.orDie), { + onOpen: write("hello").pipe(Effect.orDie), + }) + }).pipe(Effect.scoped, Effect.provide(recorder)), + ), + ) + + expect(readCassette(`${directory.path}/websocket/constructor-record.json`).interactions).toEqual([ { transport: "websocket", - open: { url: "wss://example.test", headers: { "content-type": "application/json" } }, - events: [{ direction: "server", kind: "text", body: "ready" }], + connection: { + sequence: 0, + url: "wss://echo.example.test/one", + protocols: ["echo.v1"], + close: { code: 1000, reason: "complete" }, + }, + events: [ + { direction: "client", kind: "text", body: "hello" }, + { direction: "server", kind: "text", body: "hello" }, + ], }, - ], + ]) }) - expect(cassette.interactions).toHaveLength(2) -}) -test("constructor replay validates dynamic connection metadata without opening a live socket", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-constructor-")) - fs.writeFileSync( - path.join(directory, "constructor.json"), - JSON.stringify({ - version: 1, + test("constructor replay validates dynamic URLs and protocols without opening a live socket", async () => { + using directory = tempDirectory("http-recorder-websocket-constructor-") + await seedCassetteDirectory(directory.path, "websocket/constructor", [ + { + transport: "websocket", + connection: { + sequence: 0, + url: "wss://events.example.test/workspaces/one", + protocols: ["events.v1"], + close: { code: 1000, reason: "complete" }, + }, + events: [ + { direction: "client", kind: "text", body: '{"type":"subscribe"}' }, + { direction: "server", kind: "text", body: '{"type":"ready"}' }, + ], + }, + ]) + const unavailableConstructor = () => { + throw new Error("unexpected live WebSocket construction") + } + const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor", { + directory: directory.path, + }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, unavailableConstructor))) + + const received = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://events.example.test/workspaces/one", { + protocols: ["events.v1"], + closeCodeIsError: () => false, + }) + const write = yield* socket.writer + const received: string[] = [] + yield* socket.runString( + (message) => { + received.push(message) + }, + { + onOpen: write('{"type":"subscribe"}').pipe(Effect.orDie), + }, + ) + return received + }).pipe(Effect.scoped, Effect.provide(recorder)), + ) + + expect(received).toEqual(['{"type":"ready"}']) + }) + + test("constructor replay rejects a different dynamic URL", async () => { + using directory = tempDirectory("http-recorder-websocket-constructor-") + await seedCassetteDirectory(directory.path, "websocket/constructor-mismatch", [ + { + transport: "websocket", + connection: { + sequence: 0, + url: "wss://events.example.test/workspaces/one", + protocols: [], + close: { code: 1000, reason: "complete" }, + }, + events: [], + }, + ]) + const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-mismatch", { + directory: directory.path, + }).pipe( + Layer.provide( + Layer.succeed(Socket.WebSocketConstructor, () => { + throw new Error("unexpected live WebSocket construction") + }), + ), + ) + + const exit = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://events.example.test/workspaces/two") + yield* socket.runString(() => {}) + }).pipe(Effect.scoped, Effect.exit, Effect.provide(recorder)), + ) + + expect(Exit.isFailure(exit)).toBe(true) + }) + + test("records WebSocket frames in observed client/server order", async () => { + using directory = tempDirectory("http-recorder-websocket-") + const response = JSON.stringify({ + type: "response.completed", + token: "server-secret", + }) + const upstream = Socket.make({ + runRaw: (handler, options) => + Effect.gen(function* () { + if (options?.onOpen) yield* options.onOpen + const result = handler(response) + if (Effect.isEffect(result)) yield* result + }), + writer: Effect.succeed(() => Effect.void), + }) + + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + yield* socket.runRaw(() => {}, { + onOpen: write(JSON.stringify({ type: "response.create", token: "client-secret" })).pipe(Effect.orDie), + }) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/record", { + directory: directory.path, + metadata: { provider: "test" }, + mode: "record", + }).pipe(Layer.provide(Layer.succeed(Socket.Socket, upstream))), + ), + ), + ) + + expect(readCassette(`${directory.path}/websocket/record.json`)).toMatchObject({ interactions: [ { transport: "websocket", - connection: { - sequence: 0, - url: "wss://example.test/events", - protocols: ["events.v1"], - close: { code: 1000, reason: "complete" }, - }, - events: [], + events: [ + { + direction: "client", + kind: "text", + body: '{"type":"response.create","token":"[REDACTED]"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed","token":"[REDACTED]"}', + }, + ], }, ], - }), - ) - - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.makeWebSocket("wss://example.test/events", { - protocols: ["events.v1"], - closeCodeIsError: () => false, - }) - yield* socket.runString(() => {}) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorder.layerWebSocketConstructor("constructor", { directory }).pipe( - Layer.provide( - Layer.succeed(Socket.WebSocketConstructor, () => { - throw new Error("unexpected live WebSocket construction") + }) + }) + + test("WebSocket replay preserves causal frame ordering", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/replay", [ + { + transport: "websocket", + events: [ + { + direction: "server", + kind: "text", + body: '{"type":"session.created"}', + }, + { + direction: "client", + kind: "text", + body: '{"type":"response.create","prompt":"hello"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed"}', + }, + ], + }, + ]) + + const received: string[] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + yield* socket.runRaw((message) => + Effect.gen(function* () { + if (typeof message !== "string") return + received.push(message) + const event: unknown = JSON.parse(message) + if (typeof event !== "object" || event === null || !("type" in event)) return + if (event.type === "session.created") yield* write('{"prompt":"hello","type":"response.create"}') + }), + ) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/replay", { + directory: directory.path, + compareClientMessagesAsJson: true, + mode: "replay", + }).pipe(Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket))), + ), + ), + ) + + expect(received).toEqual(['{"type":"session.created"}', '{"type":"response.completed"}']) + }) + + test("the public socket decorator replays a causal provider conversation", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/public-layer", [ + { + transport: "websocket", + events: [ + { + direction: "server", + kind: "text", + body: '{"type":"session.created"}', + }, + { + direction: "client", + kind: "text", + body: '{"type":"response.create","prompt":"first"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed","id":"first"}', + }, + { + direction: "client", + kind: "text", + body: '{"type":"response.create","prompt":"second"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed","id":"second"}', + }, + ], + }, + ]) + + const received: string[] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + yield* socket.runString((message) => + Effect.gen(function* () { + received.push(message) + const event: unknown = JSON.parse(message) + if (typeof event !== "object" || event === null) return + if ("type" in event && event.type === "session.created") { + yield* write('{"prompt":"first","type":"response.create"}') + return + } + if ("id" in event && event.id === "first") { + yield* write('{"prompt":"second","type":"response.create"}') + return + } + yield* write(new Socket.CloseEvent(1000, "done")) + }), + ) + }).pipe( + Effect.scoped, + Effect.provide( + HttpRecorder.layerSocket("websocket/public-layer", { directory: directory.path }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(received).toEqual([ + '{"type":"session.created"}', + '{"type":"response.completed","id":"first"}', + '{"type":"response.completed","id":"second"}', + ]) + }) + + test("WebSocket replay runs message handlers concurrently", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/concurrent-handlers", [ + { + transport: "websocket", + events: [ + { direction: "server", kind: "text", body: "first" }, + { direction: "server", kind: "text", body: "second" }, + ], + }, + ]) + + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const second = yield* Deferred.make() + yield* socket.runString((message) => + message === "first" ? Deferred.await(second) : Deferred.succeed(second, undefined), + ) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/concurrent-handlers", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + }) + + test("rejected concurrent replay does not consume the next interaction", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/concurrent-runs", [ + { transport: "websocket", events: [{ direction: "server", kind: "text", body: "first" }] }, + { transport: "websocket", events: [{ direction: "server", kind: "text", body: "second" }] }, + ]) + + const received: string[] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const first = yield* socket + .runString((message) => + Effect.gen(function* () { + received.push(message) + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) }), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(started) + + const concurrent = yield* Effect.exit(socket.runString(() => Effect.void)) + expect(failureText(concurrent)).toContain("Concurrent runs") + + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(first) + yield* socket.runString((message) => Effect.sync(() => received.push(message))) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/concurrent-runs", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), ), ), ), - ), - ) - fs.rmSync(directory, { recursive: true, force: true }) + ) + + expect(received).toEqual(["first", "second"]) + }) + + test("WebSocket replay rejects close with unconsumed events", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/early-close", [ + { + transport: "websocket", + events: [{ direction: "client", kind: "text", body: "expected" }], + }, + ]) + + const exit = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + return yield* Effect.exit( + socket.runRaw(() => {}, { + onOpen: write(new Socket.CloseEvent(1000)).pipe(Effect.orDie), + }), + ) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/early-close", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(failureText(exit)).toContain("closed with unconsumed events") + }) + + test("failed WebSocket runs do not write complete cassettes", async () => { + using directory = tempDirectory("http-recorder-websocket-") + const exit = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + return yield* Effect.exit(socket.runRaw(() => {})) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/failed-run", { directory: directory.path, mode: "record" }).pipe( + Layer.provide( + Layer.succeed( + Socket.Socket, + Socket.make({ + runRaw: () => Effect.die(new Error("connection failed")), + writer: Effect.succeed(() => Effect.void), + }), + ), + ), + ), + ), + ), + ) + + expect(Exit.isFailure(exit)).toBe(true) + expect(existsSync(`${directory.path}/websocket/failed-run.json`)).toBe(false) + }) + + test("WebSocket replay preserves binary frame kinds across reconnects", async () => { + using directory = tempDirectory("http-recorder-websocket-") + const interaction = { + transport: "websocket" as const, + events: [ + { + direction: "client" as const, + kind: "binary" as const, + body: Buffer.from([1, 2]).toString("base64"), + bodyEncoding: "base64" as const, + }, + { + direction: "server" as const, + kind: "binary" as const, + body: Buffer.from([3, 4]).toString("base64"), + bodyEncoding: "base64" as const, + }, + ], + } + await seedCassetteDirectory(directory.path, "websocket/binary", [interaction, interaction]) + + const received: number[][] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + const run = socket.runRaw( + (message) => { + if (typeof message === "string") throw new Error("Expected a binary WebSocket frame") + received.push([...message]) + }, + { onOpen: write(new Uint8Array([1, 2])).pipe(Effect.orDie) }, + ) + yield* run + yield* run + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/binary", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(received).toEqual([ + [3, 4], + [3, 4], + ]) + }) }) diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index ffacc5003b8a..13178ba0788c 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -303,7 +303,7 @@ recorded.effect("streams text", () => ) ``` -Replay is the default. `RECORD=true` records fresh cassettes and requires the listed env vars. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable. +Replay is the default. `RECORD=true` records fresh cassettes locally and requires the listed env vars; unset `CI` before recording because CI always forces replay. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable. Pass `provider`, `protocol`, and optional `tags` to `recordedTests(...)` / `recorded.effect.with(...)` so cassettes carry searchable metadata. Use recorded-test filters to replay or record a narrow subset without rewriting a whole file: diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index ef67c866d8d8..ae09cf7120fa 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -12,7 +12,6 @@ const openAI = OpenAI.configure({ }) const openAIChat = openAI.chat("gpt-4o-mini") const openAIResponses = openAI.responses("gpt-5.5") -const openAIResponsesWebSocket = openAI.responsesWebSocket("gpt-4.1-mini") const anthropic = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", }) @@ -89,14 +88,6 @@ describeRecordedGoldenScenarios([ { id: "image-tool-result", temperature: false, maxTokens: 40 }, ], }, - { - name: "OpenAI Responses WebSocket gpt-4.1-mini", - prefix: "openai-responses-websocket", - model: openAIResponsesWebSocket, - transport: "websocket", - requires: ["OPENAI_API_KEY"], - scenarios: ["tool-loop"], - }, { name: "Anthropic Haiku 4.5", prefix: "anthropic-messages", diff --git a/packages/llm/test/recorded-test.ts b/packages/llm/test/recorded-test.ts index 669b8de5c5ac..91087962c2d4 100644 --- a/packages/llm/test/recorded-test.ts +++ b/packages/llm/test/recorded-test.ts @@ -1,11 +1,9 @@ -import { NodeFileSystem } from "@effect/platform-node" import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" +import { existsSync, rmSync } from "node:fs" import * as path from "node:path" import { fileURLToPath } from "node:url" -import { LLMClient, RequestExecutor } from "../src/route" +import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route" import type { Service as LLMClientService } from "../src/route/client" import type { Service as RequestExecutorService } from "../src/route/executor" import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" @@ -14,7 +12,6 @@ import { type RecordedCaseOptions as RunnerCaseOptions, type RecordedGroupOptions, } from "./recorded-runner" -import { webSocketCassetteLayer } from "./recorded-websocket" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings") @@ -64,31 +61,27 @@ export const recordedTests = (options: RecordedTestsOptions) => recordedEffectGroup({ duplicateLabel: "recorded cassette", options, - cassetteExists: (cassette) => HttpRecorderInternal.hasCassetteSync(cassette, { directory: FIXTURES_DIR }), + cassetteExists: (cassette) => existsSync(path.join(FIXTURES_DIR, `${cassette}.json`)), layer: ({ cassette, metadata, options, caseOptions, recording }) => { const recorderOptions = mergeOptions(options.options, caseOptions.options) const recorderMetadata = { ...recorderOptions?.metadata, ...metadata, } - const mode = recording ? "record" : "replay" - const cassetteService = HttpRecorderInternal.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe( - Layer.provide(NodeFileSystem.layer), - ) + if (recording) { + if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") + rmSync(path.join(FIXTURES_DIR, `${cassette}.json`), { force: true }) + } const requestExecutor = RequestExecutor.layer.pipe( Layer.provide( - HttpRecorderInternal.recordingLayer(cassette, { - mode, + HttpRecorder.layerFetch(cassette, { + ...recorderOptions, + directory: FIXTURES_DIR, metadata: recorderMetadata, - redactor: HttpRecorderInternal.Redactor.make(recorderOptions?.redact), - match: recorderOptions?.match, - }).pipe(Layer.provide(FetchHttpClient.layer)), + }), ), ) - const deps = Layer.mergeAll( - requestExecutor, - webSocketCassetteLayer(cassette, { metadata: recorderMetadata, mode }), - ) - return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps))).pipe(Layer.provide(cassetteService)) + const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer) + return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps))) }, }) diff --git a/packages/llm/test/recorded-websocket.ts b/packages/llm/test/recorded-websocket.ts deleted file mode 100644 index 0b6c64f2151d..000000000000 --- a/packages/llm/test/recorded-websocket.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" -import { Effect, Layer } from "effect" -import { WebSocketExecutor } from "../src/route" -import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" - -const liveWebSocket = WebSocketExecutor.open - -export const webSocketCassetteLayer = ( - cassette: string, - input: { readonly metadata?: HttpRecorder.CassetteMetadata; readonly mode: HttpRecorderInternal.RecordReplayMode }, -): Layer.Layer => - Layer.effect( - WebSocketExecutor.Service, - Effect.gen(function* () { - const cassetteService = yield* HttpRecorderInternal.Cassette.Service - const executor = yield* HttpRecorderInternal.makeWebSocketExecutor({ - name: cassette, - mode: input.mode, - metadata: input.metadata, - cassette: cassetteService, - live: { open: liveWebSocket }, - compareClientMessagesAsJson: true, - }) - return WebSocketExecutor.Service.of(executor) - }), - ) diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 7ca09910bf80..38cb662632cc 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -2,10 +2,10 @@ import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" import { ModelsDev } from "@opencode-ai/core/models-dev" import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { describe, expect, test } from "bun:test" import { tool, type ModelMessage, type JSONValue } from "ai" import { Effect, Layer, Option, Schema, Stream } from "effect" +import { existsSync, rmSync } from "node:fs" import path from "node:path" import z from "zod" import { Auth } from "@/auth" @@ -222,9 +222,7 @@ function isSelected(scenario: RecordedScenario) { } const canRun = (scenario: RecordedScenario) => - shouldRecord - ? scenario.canRecord() - : HttpRecorderInternal.hasCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) + shouldRecord ? scenario.canRecord() : existsSync(path.join(FIXTURES_DIR, `${scenario.cassette}.json`)) const recordError = (scenario: RecordedScenario) => scenario.id === "openai-oauth" @@ -272,14 +270,11 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { url: (url: string) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"), body: redactRecordedBody, } - const recordedHttp = shouldRecord - ? HttpRecorderInternal.cassetteLayer(scenario.cassette, { - directory: FIXTURES_DIR, - mode: "record", - metadata, - redactor: HttpRecorderInternal.Redactor.make(redact), - }) - : HttpRecorder.http(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact }) + if (shouldRecord) { + if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") + rmSync(path.join(FIXTURES_DIR, `${scenario.cassette}.json`), { force: true }) + } + const recordedHttp = HttpRecorder.layerFetch(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact }) return AppNodeBuilder.build(LayerNode.group([Provider.node, LLM.node]), [ [LayerNodePlatform.requestExecutor, RequestExecutor.layer.pipe(Layer.provide(recordedHttp))], [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: true })], From 921dc54f4c51685ae9e386e1c6239c1e832a20c4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 6 Jul 2026 20:27:22 -0400 Subject: [PATCH 3/3] feat(http-recorder): expose cassette lifecycle helpers --- .../core/test/session-runner-recorded.test.ts | 3 +-- packages/http-recorder/README.md | 4 ++++ .../http-recorder/script/verify-package.ts | 4 +++- packages/http-recorder/src/cassette/store.ts | 5 ++++- packages/http-recorder/src/index.ts | 5 ++++- packages/http-recorder/test/cassette.test.ts | 21 +++++++++++++++++++ packages/llm/test/recorded-test.ts | 5 ++--- .../test/session/llm-native-recorded.test.ts | 7 ++++--- 8 files changed, 43 insertions(+), 11 deletions(-) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index d881c25efd4d..38c017af2b26 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -39,7 +39,6 @@ import { McpGuidance } from "@opencode-ai/core/mcp/guidance" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" -import { rmSync } from "node:fs" import path from "node:path" import { testEffect } from "./lib/effect" @@ -47,7 +46,7 @@ const cassetteName = "session-runner/openai-chat-streams-text" const cassetteDirectory = path.resolve(import.meta.dir, "fixtures/recordings") if (process.env.RECORD === "true") { if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") - rmSync(path.join(cassetteDirectory, `${cassetteName}.json`), { force: true }) + HttpRecorder.removeCassetteSync(cassetteName, { directory: cassetteDirectory }) } const cassette = HttpRecorder.layerFetch(cassetteName, { directory: cassetteDirectory }) const executor = RequestExecutor.layer.pipe(Layer.provide(cassette)) diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index c0fab081b823..84c539081ecc 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -85,10 +85,14 @@ HttpRecorder.layer(name, options?) HttpRecorder.layerFetch(name, options?) HttpRecorder.layerSocket(name, options?) HttpRecorder.layerWebSocketConstructor(name, options?) +HttpRecorder.hasCassetteSync(name, options?) +HttpRecorder.removeCassetteSync(name, options?) ``` That is the complete runtime API. `layer` decorates an application-provided `HttpClient`; `layerFetch` is the convenience layer that supplies Effect's fetch client. `layerWebSocketConstructor` decorates Effect's `Socket.WebSocketConstructor`, recording every dynamically selected URL and protocol. `layerSocket` is the lower-level transport-neutral decorator for an application-provided `Socket.Socket`. +Use `hasCassetteSync` when registering fixture-gated tests. `removeCassetteSync` explicitly removes one cassette before a focused refresh; removing a missing cassette is a no-op. Both helpers use the same cassette-name validation and default directory as the recorder layers. + Use `layer` to record through another Effect HTTP transport: ```ts diff --git a/packages/http-recorder/script/verify-package.ts b/packages/http-recorder/script/verify-package.ts index 83e55518251a..04f1f2bca5c4 100644 --- a/packages/http-recorder/script/verify-package.ts +++ b/packages/http-recorder/script/verify-package.ts @@ -34,6 +34,8 @@ const options: HttpRecorder.RecorderOptions = { match: () => true, redact: { jso const socketOptions: HttpRecorder.SocketRecorderOptions = { redact: { jsonFields: ["access_token"] } } HttpRecorder.layer("consumer", options) satisfies Layer.Layer HttpRecorder.layerFetch("consumer", options) satisfies Layer.Layer +HttpRecorder.hasCassetteSync("consumer", { directory: "recordings" }) satisfies boolean +HttpRecorder.removeCassetteSync("consumer", { directory: "recordings" }) HttpRecorder.layerSocket("consumer/socket", socketOptions).pipe( Layer.provide(NodeSocket.layerWebSocket("wss://example.test")), ) satisfies Layer.Layer @@ -54,7 +56,7 @@ if (JSON.stringify(root) !== JSON.stringify(["HttpRecorder"])) { } const namespace = Object.keys(HttpRecorder).sort() -if (JSON.stringify(namespace) !== JSON.stringify(["layer", "layerFetch", "layerSocket", "layerWebSocketConstructor"])) { +if (JSON.stringify(namespace) !== JSON.stringify(["hasCassetteSync", "layer", "layerFetch", "layerSocket", "layerWebSocketConstructor", "removeCassetteSync"])) { throw new Error(\`Unexpected HttpRecorder exports: \${namespace}\`) } `, diff --git a/packages/http-recorder/src/cassette/store.ts b/packages/http-recorder/src/cassette/store.ts index 1feb2113e667..2079632bea97 100644 --- a/packages/http-recorder/src/cassette/store.ts +++ b/packages/http-recorder/src/cassette/store.ts @@ -1,5 +1,5 @@ import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect" -import { existsSync } from "node:fs" +import { existsSync, rmSync } from "node:fs" import path from "node:path" import { secretFindings, SecretFindingSchema, type SecretFinding } from "../redaction/secrets.js" import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./model.js" @@ -63,6 +63,9 @@ const cassettePath = (directory: string, name: string) => { export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name)) +export const removeCassetteSync = (name: string, options: { readonly directory?: string } = {}) => + rmSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name), { force: true }) + const buildCassette = ( name: string, interactions: ReadonlyArray, diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts index 6a1cd2a135e9..2dcba03e6fcb 100644 --- a/packages/http-recorder/src/index.ts +++ b/packages/http-recorder/src/index.ts @@ -2,6 +2,7 @@ import { Layer } from "effect" import { HttpClient } from "effect/unstable/http" import { Socket } from "effect/unstable/socket" import { Api } from "./api.js" +import { hasCassetteSync, removeCassetteSync } from "./cassette/store.js" import { layer, layerFetch } from "./http/recorder.js" import { layerSocket, layerWebSocketConstructor } from "./websocket/recorder.js" @@ -20,7 +21,9 @@ export const HttpRecorder: { name: string, options?: Api.SocketRecorderOptions, ) => Layer.Layer -} = { layer, layerFetch, layerSocket, layerWebSocketConstructor } + readonly hasCassetteSync: (name: string, options?: { readonly directory?: string }) => boolean + readonly removeCassetteSync: (name: string, options?: { readonly directory?: string }) => void +} = { hasCassetteSync, layer, layerFetch, layerSocket, layerWebSocketConstructor, removeCassetteSync } export namespace HttpRecorder { /** Additional JSON metadata stored with a cassette. */ diff --git a/packages/http-recorder/test/cassette.test.ts b/packages/http-recorder/test/cassette.test.ts index d793c82fe231..d15e7628dea5 100644 --- a/packages/http-recorder/test/cassette.test.ts +++ b/packages/http-recorder/test/cassette.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Exit } from "effect" import { existsSync, readdirSync, writeFileSync } from "node:fs" import type { Interaction } from "../src/cassette/model" +import { HttpRecorder } from "../src" import { Service, hasCassetteSync, memory } from "../src/cassette/store" import { cassetteLayer } from "../src/http/recorder" import { failureText, post, readCassette, runFileCassette, seedCassetteDirectory, tempDirectory } from "./support" @@ -135,6 +136,26 @@ describe("cassette", () => { expect(() => hasCassetteSync("C:\\outside", { directory: directory.path })).toThrow("Invalid cassette name") }) + test("public cassette lifecycle helpers check and remove a recording", async () => { + using directory = tempDirectory("http-recorder-lifecycle-") + const options = { directory: directory.path } + expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(false) + + await seedCassetteDirectory(directory.path, "nested/example", [ + { + transport: "http", + request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, + response: { status: 200, headers: {}, body: "safe" }, + }, + ]) + expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(true) + + HttpRecorder.removeCassetteSync("nested/example", options) + expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(false) + expect(() => HttpRecorder.removeCassetteSync("nested/example", options)).not.toThrow() + expect(() => HttpRecorder.removeCassetteSync("../outside", options)).toThrow("Invalid cassette name") + }) + test("Cassette.list enumerates recorded cassette names", async () => { using directory = tempDirectory("http-recorder-list-") await seedCassetteDirectory(directory.path, "alpha/one", [ diff --git a/packages/llm/test/recorded-test.ts b/packages/llm/test/recorded-test.ts index 91087962c2d4..da4008577971 100644 --- a/packages/llm/test/recorded-test.ts +++ b/packages/llm/test/recorded-test.ts @@ -1,6 +1,5 @@ import { HttpRecorder } from "@opencode-ai/http-recorder" import { Layer } from "effect" -import { existsSync, rmSync } from "node:fs" import * as path from "node:path" import { fileURLToPath } from "node:url" import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route" @@ -61,7 +60,7 @@ export const recordedTests = (options: RecordedTestsOptions) => recordedEffectGroup({ duplicateLabel: "recorded cassette", options, - cassetteExists: (cassette) => existsSync(path.join(FIXTURES_DIR, `${cassette}.json`)), + cassetteExists: (cassette) => HttpRecorder.hasCassetteSync(cassette, { directory: FIXTURES_DIR }), layer: ({ cassette, metadata, options, caseOptions, recording }) => { const recorderOptions = mergeOptions(options.options, caseOptions.options) const recorderMetadata = { @@ -70,7 +69,7 @@ export const recordedTests = (options: RecordedTestsOptions) => } if (recording) { if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") - rmSync(path.join(FIXTURES_DIR, `${cassette}.json`), { force: true }) + HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR }) } const requestExecutor = RequestExecutor.layer.pipe( Layer.provide( diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 38cb662632cc..36853a6d268c 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -5,7 +5,6 @@ import { HttpRecorder } from "@opencode-ai/http-recorder" import { describe, expect, test } from "bun:test" import { tool, type ModelMessage, type JSONValue } from "ai" import { Effect, Layer, Option, Schema, Stream } from "effect" -import { existsSync, rmSync } from "node:fs" import path from "node:path" import z from "zod" import { Auth } from "@/auth" @@ -222,7 +221,9 @@ function isSelected(scenario: RecordedScenario) { } const canRun = (scenario: RecordedScenario) => - shouldRecord ? scenario.canRecord() : existsSync(path.join(FIXTURES_DIR, `${scenario.cassette}.json`)) + shouldRecord + ? scenario.canRecord() + : HttpRecorder.hasCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) const recordError = (scenario: RecordedScenario) => scenario.id === "openai-oauth" @@ -272,7 +273,7 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { } if (shouldRecord) { if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") - rmSync(path.join(FIXTURES_DIR, `${scenario.cassette}.json`), { force: true }) + HttpRecorder.removeCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) } const recordedHttp = HttpRecorder.layerFetch(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact }) return AppNodeBuilder.build(LayerNode.group([Provider.node, LLM.node]), [