@neurocode-ai/http-recorder 1.18.13 → 1.18.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "1.18.13",
3
+ "version": "1.18.15",
4
4
  "name": "@neurocode-ai/http-recorder",
5
5
  "description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
6
6
  "type": "module",
@@ -37,6 +37,7 @@
37
37
  "./internal": "./src/internal.ts"
38
38
  },
39
39
  "files": [
40
+ "src",
40
41
  "dist",
41
42
  "README.md",
42
43
  "CHANGELOG.md",
@@ -0,0 +1,179 @@
1
+ import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect"
2
+ import * as fs from "node:fs"
3
+ import * as path from "node:path"
4
+ import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js"
5
+ import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js"
6
+
7
+ const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
8
+
9
+ export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
10
+ cassetteName: Schema.String,
11
+ }) {
12
+ override get message() {
13
+ return `Cassette "${this.cassetteName}" not found`
14
+ }
15
+ }
16
+
17
+ export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
18
+ cassetteName: Schema.String,
19
+ findings: Schema.Array(SecretFindingSchema),
20
+ }) {
21
+ override get message() {
22
+ return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
23
+ .map((finding) => `${finding.path} (${finding.reason})`)
24
+ .join(", ")}`
25
+ }
26
+ }
27
+
28
+ export interface Interface {
29
+ readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
30
+ readonly append: (
31
+ name: string,
32
+ interaction: Interaction,
33
+ metadata?: CassetteMetadata,
34
+ ) => Effect.Effect<void, UnsafeCassetteError>
35
+ readonly exists: (name: string) => Effect.Effect<boolean>
36
+ readonly list: () => Effect.Effect<ReadonlyArray<string>>
37
+ }
38
+
39
+ export class Service extends Context.Service<Service, Interface>()("@neurocode-ai/http-recorder/Cassette") {}
40
+
41
+ const cassettePath = (directory: string, name: string) => {
42
+ if (!name || path.isAbsolute(name) || path.win32.isAbsolute(name) || name.split(/[\\/]/).includes(".."))
43
+ throw new Error(`Invalid cassette name "${name}"`)
44
+ const root = path.resolve(directory)
45
+ const target = path.resolve(root, `${name}.json`)
46
+ const relative = path.relative(root, target)
47
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
48
+ throw new Error(`Invalid cassette name "${name}"`)
49
+ return target
50
+ }
51
+
52
+ export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
53
+ fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name))
54
+
55
+ const buildCassette = (
56
+ name: string,
57
+ interactions: ReadonlyArray<Interaction>,
58
+ metadata: CassetteMetadata | undefined,
59
+ ): Cassette => ({
60
+ version: 1,
61
+ metadata: { name, recordedAt: new Date().toISOString(), ...metadata },
62
+ interactions,
63
+ })
64
+
65
+ const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
66
+
67
+ const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema))
68
+
69
+ const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
70
+ findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
71
+
72
+ export const fileSystem = (
73
+ options: { readonly directory?: string } = {},
74
+ ): Layer.Layer<Service, never, FileSystem.FileSystem> =>
75
+ Layer.effect(
76
+ Service,
77
+ Effect.gen(function* () {
78
+ const fs = yield* FileSystem.FileSystem
79
+ const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
80
+ const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
81
+ const appendLock = yield* Semaphore.make(1)
82
+
83
+ const pathFor = (name: string) => cassettePath(directory, name)
84
+
85
+ const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
86
+ Effect.gen(function* () {
87
+ const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
88
+ const nested = yield* Effect.forEach(entries, (entry) => {
89
+ const full = path.join(current, entry)
90
+ return fs.stat(full).pipe(
91
+ Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
92
+ Effect.catch(() => Effect.succeed([] as string[])),
93
+ )
94
+ })
95
+ return nested.flat()
96
+ })
97
+
98
+ return Service.of({
99
+ read: (name) =>
100
+ fs.readFileString(pathFor(name)).pipe(
101
+ Effect.map((raw) => parseCassette(raw).interactions),
102
+ Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
103
+ ),
104
+ append: (name, interaction, metadata) =>
105
+ appendLock.withPermit(
106
+ Effect.gen(function* () {
107
+ const entry = recorded.get(name) ?? { interactions: [], findings: [] }
108
+ const interactions = [...entry.interactions, interaction]
109
+ const interactionFindings = [...entry.findings, ...secretFindings(interaction)]
110
+ const cassette = buildCassette(name, interactions, metadata)
111
+ const findings = [...interactionFindings, ...secretFindings(cassette.metadata ?? {})]
112
+ yield* failIfUnsafe(name, findings)
113
+ const target = pathFor(name)
114
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie)
115
+ const temporary = `${target}.${crypto.randomUUID()}.tmp`
116
+ yield* fs.writeFileString(temporary, formatCassette(cassette)).pipe(
117
+ Effect.flatMap(() => fs.rename(temporary, target)),
118
+ Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.catch(() => Effect.void))),
119
+ Effect.orDie,
120
+ )
121
+ recorded.set(name, { interactions, findings: interactionFindings })
122
+ }),
123
+ ),
124
+ exists: (name) =>
125
+ fs.access(pathFor(name)).pipe(
126
+ Effect.as(true),
127
+ Effect.catch(() => Effect.succeed(false)),
128
+ ),
129
+ list: () =>
130
+ walk(directory).pipe(
131
+ Effect.map((files) =>
132
+ files
133
+ .filter((file) => file.endsWith(".json"))
134
+ .map((file) =>
135
+ path
136
+ .relative(directory, file)
137
+ .replace(/\\/g, "/")
138
+ .replace(/\.json$/, ""),
139
+ )
140
+ .toSorted((a, b) => a.localeCompare(b)),
141
+ ),
142
+ ),
143
+ })
144
+ }),
145
+ )
146
+
147
+ export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
148
+ Layer.sync(Service, () => {
149
+ const stored = new Map<string, Interaction[]>(
150
+ Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
151
+ )
152
+ const accumulatedFindings = new Map<string, SecretFinding[]>()
153
+ const appendLock = Semaphore.makeUnsafe(1)
154
+
155
+ return Service.of({
156
+ read: (name) =>
157
+ stored.has(name)
158
+ ? Effect.succeed(stored.get(name) ?? [])
159
+ : Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
160
+ append: (name, interaction, metadata) =>
161
+ appendLock.withPermit(
162
+ Effect.suspend(() => {
163
+ const interactions = [...(stored.get(name) ?? []), interaction]
164
+ const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)]
165
+ const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings
166
+ return failIfUnsafe(name, allFindings).pipe(
167
+ Effect.tap(() =>
168
+ Effect.sync(() => {
169
+ stored.set(name, interactions)
170
+ accumulatedFindings.set(name, findings)
171
+ }),
172
+ ),
173
+ )
174
+ }),
175
+ ),
176
+ exists: (name) => Effect.sync(() => stored.has(name)),
177
+ list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
178
+ })
179
+ })
package/src/effect.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { NodeFileSystem } from "@effect/platform-node"
2
+ import * as Layer from "effect/Layer"
3
+ import { FetchHttpClient } from "effect/unstable/http"
4
+ import type * as HttpClient from "effect/unstable/http/HttpClient"
5
+ import * as CassetteService from "./cassette.js"
6
+ import { recordingLayer } from "./internal-effect.js"
7
+ import { make } from "./redactor.js"
8
+ import type { RecorderOptions } from "./types.js"
9
+
10
+ /**
11
+ * Provides a fetch-backed `HttpClient` with cassette recording and replay.
12
+ *
13
+ * Locally, a missing cassette is recorded from the real service. Existing
14
+ * cassettes are replayed, and `CI=true` makes a missing cassette fail.
15
+ */
16
+ export const http = (name: string, options: RecorderOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
17
+ recordingLayer(name, {
18
+ metadata: options.metadata,
19
+ redactor: make(options.redact),
20
+ match: options.match,
21
+ }).pipe(
22
+ Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
23
+ Layer.provide(FetchHttpClient.layer),
24
+ Layer.provide(NodeFileSystem.layer),
25
+ )
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { http } from "./effect.js"
2
+ import { socket } from "./socket.js"
3
+
4
+ /** HTTP and WebSocket cassette recording. */
5
+ export const HttpRecorder = { http, socket } as const
6
+
7
+ export namespace HttpRecorder {
8
+ /** Additional JSON metadata stored with a cassette. */
9
+ export type CassetteMetadata = import("./types.js").CassetteMetadata
10
+ /** Recorder configuration. */
11
+ export type RecorderOptions = import("./types.js").RecorderOptions
12
+ /** Additive redaction and header-preservation policy. */
13
+ export type RedactOptions = import("./types.js").RedactOptions
14
+ /** Returns whether an incoming HTTP request matches a recorded request. */
15
+ export type RequestMatcher = import("./types.js").RequestMatcher
16
+ /** The normalized HTTP request representation used for matching. */
17
+ export type RequestSnapshot = import("./types.js").RequestSnapshot
18
+ }
@@ -0,0 +1,189 @@
1
+ import { NodeFileSystem } from "@effect/platform-node"
2
+ import { Deferred, Effect, Layer, Option, Ref } from "effect"
3
+ import {
4
+ FetchHttpClient,
5
+ Headers,
6
+ HttpBody,
7
+ HttpClient,
8
+ HttpClientError,
9
+ HttpClientRequest,
10
+ HttpClientResponse,
11
+ UrlParams,
12
+ } from "effect/unstable/http"
13
+ import * as CassetteService from "./cassette.js"
14
+ import { defaultMatcher, selectSequential } from "./matching.js"
15
+ import { makeReplayState, resolveAutoMode } from "./recorder.js"
16
+ import { make, type Redactor } from "./redactor.js"
17
+ import { redactUrl } from "./redaction.js"
18
+ import { httpInteractions } from "./schema.js"
19
+ import type { CassetteMetadata, HttpInteraction, RequestMatcher, ResponseSnapshot } from "./types.js"
20
+
21
+ export { defaultMatcher }
22
+
23
+ export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
24
+
25
+ export interface RecordReplayOptions {
26
+ readonly mode?: RecordReplayMode
27
+ readonly directory?: string
28
+ readonly metadata?: CassetteMetadata
29
+ readonly redactor?: Redactor
30
+ readonly match?: RequestMatcher
31
+ }
32
+
33
+ const TEXT_CONTENT_TYPES = new Set([
34
+ "application/graphql",
35
+ "application/javascript",
36
+ "application/json",
37
+ "application/sql",
38
+ "application/x-www-form-urlencoded",
39
+ "application/xml",
40
+ "application/yaml",
41
+ "image/svg+xml",
42
+ ])
43
+
44
+ const isTextContentType = (contentType: string | undefined) => {
45
+ const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase()
46
+ if (!mediaType) return false
47
+ return (
48
+ mediaType.startsWith("text/") ||
49
+ mediaType.endsWith("+json") ||
50
+ mediaType.endsWith("+xml") ||
51
+ TEXT_CONTENT_TYPES.has(mediaType)
52
+ )
53
+ }
54
+
55
+ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
56
+ response.arrayBuffer.pipe(
57
+ Effect.map((bytes) =>
58
+ isTextContentType(contentType)
59
+ ? { body: new TextDecoder().decode(bytes) }
60
+ : { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const },
61
+ ),
62
+ )
63
+
64
+ const decodeResponseBody = (snapshot: ResponseSnapshot) =>
65
+ snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
66
+
67
+ const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snapshot: ResponseSnapshot) =>
68
+ HttpClientResponse.fromWeb(
69
+ request,
70
+ new Response(
71
+ request.method === "HEAD" || snapshot.status === 204 || snapshot.status === 205 || snapshot.status === 304
72
+ ? null
73
+ : decodeResponseBody(snapshot),
74
+ snapshot,
75
+ ),
76
+ )
77
+
78
+ export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
79
+ HttpClientRequest.makeWith(
80
+ request.method,
81
+ redactUrl(request.url),
82
+ UrlParams.empty,
83
+ Option.none(),
84
+ Headers.empty,
85
+ HttpBody.empty,
86
+ )
87
+
88
+ const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) =>
89
+ new HttpClientError.HttpClientError({
90
+ reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
91
+ })
92
+
93
+ export const recordingLayer = (
94
+ name: string,
95
+ options: Omit<RecordReplayOptions, "directory"> = {},
96
+ ): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | CassetteService.Service> =>
97
+ Layer.effect(
98
+ HttpClient.HttpClient,
99
+ Effect.gen(function* () {
100
+ const upstream = yield* HttpClient.HttpClient
101
+ const cassetteService = yield* CassetteService.Service
102
+ const redactor = options.redactor ?? make()
103
+ const match = options.match ?? defaultMatcher
104
+ const requested = options.mode ?? "auto"
105
+ const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
106
+
107
+ const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
108
+ Effect.gen(function* () {
109
+ const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
110
+ return redactor.request({
111
+ method: web.method,
112
+ url: web.url,
113
+ headers: Object.fromEntries(web.headers.entries()),
114
+ body: yield* Effect.promise(() => web.text()),
115
+ })
116
+ })
117
+
118
+ if (mode === "passthrough") return upstream
119
+
120
+ if (mode === "record") {
121
+ const initial = yield* Deferred.make<void>()
122
+ yield* Deferred.succeed(initial, undefined)
123
+ const tail = yield* Ref.make(initial)
124
+ return HttpClient.make((request) =>
125
+ Effect.gen(function* () {
126
+ const completed = yield* Deferred.make<void>()
127
+ const previous = yield* Ref.modify(tail, (current) => [current, completed])
128
+ return yield* Effect.gen(function* () {
129
+ const incoming = yield* snapshotRequest(request)
130
+ const response = yield* upstream.execute(request)
131
+ const captured = yield* captureResponseBody(response, response.headers["content-type"])
132
+ const responseSnapshot: ResponseSnapshot = {
133
+ status: response.status,
134
+ headers: response.headers as Record<string, string>,
135
+ ...captured,
136
+ }
137
+ const interaction: HttpInteraction = {
138
+ transport: "http",
139
+ request: incoming,
140
+ response: redactor.response(responseSnapshot),
141
+ }
142
+ yield* Deferred.await(previous)
143
+ yield* cassetteService
144
+ .append(name, interaction, options.metadata)
145
+ .pipe(
146
+ Effect.catchTag("UnsafeCassetteError", (error) =>
147
+ Effect.fail(transportError(request, error.message)),
148
+ ),
149
+ )
150
+ return responseFromSnapshot(request, responseSnapshot)
151
+ }).pipe(Effect.ensuring(Deferred.succeed(completed, undefined)))
152
+ }),
153
+ )
154
+ }
155
+
156
+ const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
157
+ return HttpClient.make((request) =>
158
+ Effect.gen(function* () {
159
+ const incoming = yield* snapshotRequest(request)
160
+ const claimed = yield* replay
161
+ .claim((interaction, index, interactions) => {
162
+ const result = selectSequential(interactions, incoming, match, index)
163
+ if (result.interaction) return Effect.void
164
+ return Effect.fail(
165
+ transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
166
+ )
167
+ })
168
+ .pipe(
169
+ Effect.mapError((error) =>
170
+ error._tag === "CassetteNotFoundError"
171
+ ? transportError(
172
+ request,
173
+ `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`,
174
+ )
175
+ : error,
176
+ ),
177
+ )
178
+ return responseFromSnapshot(request, claimed.interaction.response)
179
+ }),
180
+ )
181
+ }),
182
+ )
183
+
184
+ export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
185
+ recordingLayer(name, options).pipe(
186
+ Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
187
+ Layer.provide(FetchHttpClient.layer),
188
+ Layer.provide(NodeFileSystem.layer),
189
+ )
@@ -0,0 +1,15 @@
1
+ export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette.js"
2
+ export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./internal-effect.js"
3
+ export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction.js"
4
+ export { socketLayer } from "./socket.js"
5
+ export {
6
+ makeWebSocketExecutor,
7
+ type WebSocketConnection,
8
+ type WebSocketExecutor,
9
+ type WebSocketRecordReplayOptions,
10
+ type WebSocketRequest,
11
+ } from "./websocket.js"
12
+ export * as Cassette from "./cassette.js"
13
+ export * as Redactor from "./redactor.js"
14
+
15
+ export * as HttpRecorderInternal from "./internal.js"
@@ -0,0 +1,106 @@
1
+ import { Option, Schema } from "effect"
2
+ import { REDACTED, secretFindings } from "./redaction.js"
3
+ import type { HttpInteraction, RequestMatcher, RequestSnapshot } from "./types.js"
4
+
5
+ const JsonValue = Schema.fromJsonString(Schema.Unknown)
6
+ export const decodeJson = Schema.decodeUnknownOption(JsonValue)
7
+
8
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
9
+ value !== null && typeof value === "object" && !Array.isArray(value)
10
+
11
+ export const canonicalizeJson = (value: unknown): unknown => {
12
+ if (Array.isArray(value)) return value.map(canonicalizeJson)
13
+ if (isRecord(value)) {
14
+ return Object.fromEntries(
15
+ Object.keys(value)
16
+ .toSorted()
17
+ .map((key) => [key, canonicalizeJson(value[key])]),
18
+ )
19
+ }
20
+ return value
21
+ }
22
+
23
+ export type { RequestMatcher } from "./types.js"
24
+
25
+ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
26
+ JSON.stringify({
27
+ method: snapshot.method,
28
+ url: snapshot.url,
29
+ headers: canonicalizeJson(snapshot.headers),
30
+ body: Option.match(decodeJson(snapshot.body), {
31
+ onNone: () => snapshot.body,
32
+ onSome: canonicalizeJson,
33
+ }),
34
+ })
35
+
36
+ export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
37
+ canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
38
+
39
+ export const safeText = (value: unknown) => {
40
+ if (value === undefined) return "undefined"
41
+ if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
42
+ const text = JSON.stringify(value)
43
+ if (!text) return typeof value
44
+ return text.length > 300 ? `${text.slice(0, 300)}...` : text
45
+ }
46
+
47
+ const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
48
+
49
+ const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
50
+ if (Object.is(expected, received)) return []
51
+ if (isRecord(expected) && isRecord(received)) {
52
+ return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
53
+ .toSorted()
54
+ .flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
55
+ .slice(0, limit)
56
+ }
57
+ if (Array.isArray(expected) && Array.isArray(received)) {
58
+ return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
59
+ .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
60
+ .slice(0, limit)
61
+ }
62
+ return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
63
+ }
64
+
65
+ const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
66
+ [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
67
+ if (expected[key] === received[key]) return []
68
+ if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
69
+ if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
70
+ return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
71
+ })
72
+
73
+ export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
74
+ const lines: string[] = []
75
+ if (expected.method !== received.method) {
76
+ lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
77
+ }
78
+ if (expected.url !== received.url) {
79
+ lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
80
+ }
81
+ const headers = headerDiffs(expected.headers, received.headers)
82
+ if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
83
+ const expectedBody = jsonBody(expected.body)
84
+ const receivedBody = jsonBody(received.body)
85
+ const body =
86
+ expectedBody !== undefined && receivedBody !== undefined
87
+ ? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
88
+ : expected.body === received.body
89
+ ? []
90
+ : [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
91
+ if (body.length > 0) lines.push("body:", ...body)
92
+ return lines
93
+ }
94
+
95
+ export const selectSequential = (
96
+ interactions: ReadonlyArray<HttpInteraction>,
97
+ incoming: RequestSnapshot,
98
+ match: RequestMatcher,
99
+ index: number,
100
+ ): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
101
+ const interaction = interactions[index]
102
+ if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
103
+ if (!match(incoming, interaction.request))
104
+ return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
105
+ return { interaction, detail: "" }
106
+ }
@@ -0,0 +1,62 @@
1
+ import { Effect, Scope, SynchronizedRef } from "effect"
2
+ import type * as CassetteService from "./cassette.js"
3
+ import type { CassetteNotFoundError } from "./cassette.js"
4
+ import type { Interaction } from "./schema.js"
5
+
6
+ const isCI = () => {
7
+ const value = process.env.CI
8
+ return value !== undefined && value !== "" && value !== "false" && value !== "0"
9
+ }
10
+
11
+ export const resolveAutoMode = (
12
+ cassette: CassetteService.Interface,
13
+ name: string,
14
+ ): Effect.Effect<"record" | "replay" | "passthrough"> =>
15
+ Effect.gen(function* () {
16
+ if (isCI()) return "replay"
17
+ return (yield* cassette.exists(name)) ? "replay" : "record"
18
+ })
19
+
20
+ export interface ReplayState<T> {
21
+ readonly claim: <E>(
22
+ validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray<T>) => Effect.Effect<void, E>,
23
+ ) => Effect.Effect<{ readonly interaction: T; readonly index: number }, CassetteNotFoundError | E>
24
+ }
25
+
26
+ export const makeReplayState = <T>(
27
+ cassette: CassetteService.Interface,
28
+ name: string,
29
+ project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
30
+ ): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
31
+ Effect.gen(function* () {
32
+ const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
33
+ const position = yield* SynchronizedRef.make(0)
34
+
35
+ yield* Effect.addFinalizer(() =>
36
+ Effect.gen(function* () {
37
+ const used = yield* SynchronizedRef.get(position)
38
+ if (used === 0) return yield* Effect.void
39
+ const interactions = yield* load.pipe(Effect.orDie)
40
+ if (used < interactions.length)
41
+ return yield* Effect.die(
42
+ new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`),
43
+ )
44
+ return yield* Effect.void
45
+ }),
46
+ )
47
+
48
+ return {
49
+ claim: (validate) =>
50
+ Effect.flatMap(load, (interactions) =>
51
+ SynchronizedRef.modifyEffect(position, (index) =>
52
+ Effect.gen(function* () {
53
+ const interaction = interactions[index]
54
+ yield* validate(interaction, index, interactions)
55
+ if (interaction === undefined)
56
+ return yield* Effect.die("Replay validation accepted a missing interaction")
57
+ return [{ interaction, index }, index + 1] as const
58
+ }),
59
+ ),
60
+ ),
61
+ }
62
+ })