mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-18 10:33:15 +00:00
feat(http-recorder): default mode to "auto" (#26719)
This commit is contained in:
@@ -16,8 +16,9 @@ import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
## Quickstart
|
||||
|
||||
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
|
||||
The first run records to `test/fixtures/recordings/<name>.json`; subsequent
|
||||
runs replay from it.
|
||||
By default the layer records on first run and replays on subsequent runs —
|
||||
no env-var ternary at the call site, and `CI=true` forces strict replay so
|
||||
missing cassettes fail loudly in CI rather than silently re-recording.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
@@ -30,28 +31,22 @@ const program = Effect.gen(function* () {
|
||||
return yield* response.json
|
||||
})
|
||||
|
||||
// Replay (default). Fails if the cassette is missing.
|
||||
// Records if the cassette is missing, replays if it exists.
|
||||
// In CI (CI=true) always replays — fails loudly on missing fixtures.
|
||||
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
|
||||
|
||||
// Record. Hits the upstream and writes the cassette.
|
||||
// Force a refresh — always hits upstream and overwrites.
|
||||
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))))
|
||||
```
|
||||
|
||||
Set the mode from the environment in your test setup:
|
||||
|
||||
```ts
|
||||
HttpRecorder.cassetteLayer("users/get-one", {
|
||||
mode: process.env.RECORD === "true" ? "record" : "replay",
|
||||
})
|
||||
```
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
| ------------- | -------------------------------------------------------------------- |
|
||||
| `replay` | Default. Match the request to a recorded interaction; error if none. |
|
||||
| `record` | Execute upstream, append the interaction, write the cassette. |
|
||||
| `passthrough` | Bypass the recorder entirely — just call upstream. |
|
||||
| Mode | Behavior |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. |
|
||||
| `replay` | Strict — match the request to a recorded interaction; error if none. |
|
||||
| `record` | Execute upstream, append the interaction, write the cassette. |
|
||||
| `passthrough` | Bypass the recorder entirely — just call upstream. |
|
||||
|
||||
## Cassette format
|
||||
|
||||
@@ -101,7 +96,6 @@ secrets escape. Redaction is configured by composing a `Redactor`:
|
||||
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
|
||||
|
||||
HttpRecorder.cassetteLayer("anthropic/messages", {
|
||||
mode: process.env.RECORD === "true" ? "record" : "replay",
|
||||
redactor: Redactor.defaults({
|
||||
requestHeaders: { allow: ["content-type", "anthropic-version"] },
|
||||
url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") },
|
||||
@@ -157,7 +151,6 @@ const program = Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "ws/subscribe",
|
||||
mode: process.env.RECORD === "true" ? "record" : "replay",
|
||||
cassette,
|
||||
live: liveExecutor,
|
||||
})
|
||||
@@ -188,7 +181,7 @@ const audit = Effect.gen(function* () {
|
||||
|
||||
```ts
|
||||
type RecordReplayOptions = {
|
||||
mode?: "record" | "replay" | "passthrough" // default: "replay"
|
||||
mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay")
|
||||
directory?: string // default: <cwd>/test/fixtures/recordings
|
||||
metadata?: Record<string, unknown> // merged into cassette.metadata
|
||||
redactor?: Redactor // default: Redactor.defaults()
|
||||
|
||||
@@ -12,12 +12,12 @@ import {
|
||||
} from "effect/unstable/http"
|
||||
import * as CassetteService from "./cassette"
|
||||
import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching"
|
||||
import { appendOrFail, makeReplayState } from "./recorder"
|
||||
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import { defaults, type Redactor } from "./redactor"
|
||||
import { redactUrl } from "./redaction"
|
||||
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
|
||||
|
||||
export type RecordReplayMode = "record" | "replay" | "passthrough"
|
||||
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
|
||||
|
||||
export interface RecordReplayOptions {
|
||||
readonly mode?: RecordReplayMode
|
||||
@@ -69,7 +69,8 @@ export const recordingLayer = (
|
||||
const cassetteService = yield* CassetteService.Service
|
||||
const redactor = options.redactor ?? defaults()
|
||||
const match = options.match ?? defaultMatcher
|
||||
const mode = options.mode ?? "replay"
|
||||
const requested = options.mode ?? "auto"
|
||||
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
|
||||
const sequential = options.dispatch === "sequential"
|
||||
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
|
||||
|
||||
@@ -114,7 +115,12 @@ export const recordingLayer = (
|
||||
return Effect.gen(function* () {
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const interactions = yield* replay.load.pipe(
|
||||
Effect.mapError(() => transportError(request, `Fixture "${name}" not found.`)),
|
||||
Effect.mapError(() =>
|
||||
transportError(
|
||||
request,
|
||||
`Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`,
|
||||
),
|
||||
),
|
||||
)
|
||||
const result = sequential
|
||||
? selectSequential(interactions, incoming, match, yield* replay.cursor)
|
||||
|
||||
@@ -17,6 +17,22 @@ export class UnsafeCassetteError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolvedMode = "record" | "replay" | "passthrough"
|
||||
|
||||
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<ResolvedMode> =>
|
||||
Effect.gen(function* () {
|
||||
if (isCI()) return "replay"
|
||||
return (yield* cassette.exists(name)) ? "replay" : "record"
|
||||
})
|
||||
|
||||
export const appendOrFail = (
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Effect, Option, Ref, Scope, Stream } from "effect"
|
||||
import type { Headers } from "effect/unstable/http"
|
||||
import * as CassetteService from "./cassette"
|
||||
import { canonicalizeJson, decodeJson } from "./matching"
|
||||
import { appendOrFail, makeReplayState } from "./recorder"
|
||||
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import type { RecordReplayMode } from "./effect"
|
||||
import { defaults, type Redactor } from "./redactor"
|
||||
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
|
||||
|
||||
@@ -23,7 +24,7 @@ export interface WebSocketExecutor<E> {
|
||||
|
||||
export interface WebSocketRecordReplayOptions<E> {
|
||||
readonly name: string
|
||||
readonly mode?: "record" | "replay" | "passthrough"
|
||||
readonly mode?: RecordReplayMode
|
||||
readonly metadata?: CassetteMetadata
|
||||
readonly cassette: CassetteService.Interface
|
||||
readonly live: WebSocketExecutor<E>
|
||||
@@ -71,7 +72,8 @@ export const makeWebSocketExecutor = <E>(
|
||||
options: WebSocketRecordReplayOptions<E>,
|
||||
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const mode = options.mode ?? "replay"
|
||||
const requested = options.mode ?? "auto"
|
||||
const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested
|
||||
const redactor = options.redactor ?? defaults()
|
||||
const openSnapshot = (request: WebSocketRequest) => {
|
||||
const redacted = redactor.request({
|
||||
|
||||
@@ -301,6 +301,59 @@ describe("http-recorder", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("auto mode replays when the cassette exists", async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
|
||||
const cassettePath = path.join(directory, "auto-replay.json")
|
||||
fs.writeFileSync(
|
||||
cassettePath,
|
||||
formatCassette(
|
||||
cassetteFor(
|
||||
"auto-replay",
|
||||
[
|
||||
{
|
||||
transport: "http",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://example.test/echo",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ step: 1 }),
|
||||
},
|
||||
response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const result = await runWith(
|
||||
"auto-replay",
|
||||
{ directory, mode: "auto" },
|
||||
post("https://example.test/echo", { step: 1 }),
|
||||
)
|
||||
expect(result).toBe('{"reply":"hi"}')
|
||||
})
|
||||
|
||||
test("auto mode 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.cassetteLayer("missing-cassette", { directory, mode: "auto" })),
|
||||
),
|
||||
),
|
||||
)
|
||||
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 closest redacted request differences", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder"
|
||||
import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { WebSocketExecutor } from "../src/route"
|
||||
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
|
||||
|
||||
const liveWebSocket = WebSocketExecutor.open
|
||||
type Mode = "record" | "replay" | "passthrough"
|
||||
|
||||
export const webSocketCassetteLayer = (
|
||||
cassette: string,
|
||||
input: { readonly metadata?: Record<string, unknown>; readonly mode: Mode },
|
||||
input: { readonly metadata?: Record<string, unknown>; readonly mode: RecordReplayMode },
|
||||
): Layer.Layer<WebSocketExecutorService, never, Cassette.Service> =>
|
||||
Layer.effect(
|
||||
WebSocketExecutor.Service,
|
||||
|
||||
Reference in New Issue
Block a user