@useairfoil/effect-vcr
v0.4.3
Published
An `HttpClient` for Effect that records HTTP interactions to cassette files and replays them for future test runs.
Readme
@useairfoil/effect-vcr
An HttpClient for Effect that records HTTP interactions to cassette files and replays them for future test runs.
The first time the following test is run, it sends the HTTP request to httpbin.org and stores the request/response pair to a cassette. All subsequent test runs replay the stored response and make no live HTTP requests.
// my-program.ts
import { Effect } from "effect";
import { HttpClient } from "effect/unstable/http";
export const program = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
const response = yield* client.get("https://httpbin.org/robots.txt");
return yield* response.text;
});
// my-program.test.ts
import { NodeServices } from "@effect/platform-node";
import { FileSystemCassetteStore, VcrHttpClient } from "@useairfoil/effect-vcr";
import { describe, it } from "@effect/vitest";
import { Effect, Layer } from "effect";
import { FetchHttpClient } from "effect/unstable/http";
const cassetteStoreLayer = FileSystemCassetteStore.layer().pipe(Layer.provide(NodeServices.layer));
const vcrRuntimeLayer = Layer.mergeAll(
FetchHttpClient.layer,
NodeServices.layer,
cassetteStoreLayer,
);
const vcrLayer = VcrHttpClient.layer({
vcrName: "httpbin",
mode: "auto",
}).pipe(Layer.provide(vcrRuntimeLayer));
describe("my awesome program", () => {
it.effect("works", () => program.pipe(Effect.provide(vcrLayer)));
});The main use case for the VCR is to let open source projects run tests against a real upstream API without exposing secrets to forks.
- project owners generate cassettes locally by talking to the real API
- external contributors run tests against committed cassettes
- CI runs deterministically from cassettes
- scheduled or manual runs can still hit the live API to catch upstream drift
Package shape
Root exports:
CassetteStoreFileSystemCassetteStoreVcrHttpClientVcrConfigVcrModeVcrRequestVcrResponseVcrEntryCassetteCassetteFile
Focused subpath exports are also available:
@useairfoil/effect-vcr/cassette-store@useairfoil/effect-vcr/file-system-cassette-store@useairfoil/effect-vcr/types@useairfoil/effect-vcr/vcr-http-client
Usage
Use the VCR by providing VcrHttpClient.layer(...) to the Effect being tested.
VcrHttpClientwraps an existingHttpClientand adds record/replay behaviorFileSystemCassetteStorepersists cassette files to disk
VcrHttpClient.layer(...) depends on:
- a live
HttpClientimplementation such asFetchHttpClient.layer Path.Pathfor cassette name resolutionCassetteStore.CassetteStorefor persistence
FileSystemCassetteStore.layer(...) depends on:
FileSystem.FileSystemPath.Path
In Node tests, NodeServices.layer satisfies those platform services.
Configuration and defaults
VcrHttpClient
type VcrConfig = {
readonly vcrName?: string;
readonly cassetteName?: string;
readonly mode?: "record" | "replay" | "auto";
readonly redact?: {
readonly requestHeaders?: ReadonlyArray<string>;
readonly responseHeaders?: ReadonlyArray<string>;
readonly requestBodyKeys?: ReadonlyArray<string>;
readonly responseBodyKeys?: ReadonlyArray<string>;
};
readonly matchIgnore?: {
readonly requestHeaders?: ReadonlyArray<string>;
readonly requestBodyKeys?: ReadonlyArray<string>;
};
readonly match?: (request: VcrRequest, entry: VcrEntry) => boolean;
};vcrName?: string- logical VCR name used by
ACK_DISABLE_VCR
- logical VCR name used by
cassetteName?: string- cassette file basename or full file name
usersresolves tousers.cassetteusers.cassetteis preserved as-is- when omitted in Vitest, the cassette file defaults to
<test-file>.cassetteand the current test name becomes the export key inside that file
mode?: "record" | "replay" | "auto"record: always call the live client and write a cassettereplay: only serve from cassette; missing entries failauto: replay if the cassette exists, otherwise record; whenCI=true, missing cassettes fail instead of recording
redact- remove sensitive headers or JSON body keys before writing to disk
matchIgnore- ignore request headers or JSON body keys when computing the request lookup key
match- custom request matcher for advanced lookup behavior
Defaults:
modedefaults to"auto"authorizationis ignored for matching by defaultauthorizationis redacted from recorded request headers by default
FileSystemCassetteStore
cassetteDir?: string- changes the cassette root directory
- when omitted in Vitest, cassettes are written to the
__cassettes__directory beside the test file - outside Vitest, provide
cassetteDirexplicitly
Matching and redaction
matchIgnore and redact solve different problems.
matchIgnorechanges how requests are matched to cassette entriesredactchanges what is persisted to disk
Typical usage:
const vcrLayer = VcrHttpClient.layer({
vcrName: "shopify-products",
mode: "auto",
matchIgnore: {
requestHeaders: ["x-shopify-access-token", "authorization"],
},
redact: {
requestHeaders: ["x-shopify-access-token", "authorization"],
},
});Use matchIgnore when a request field should not affect cassette identity.
Use redact when a field should never be written to disk.
Runtime configuration
The VCR is controlled by the following environment variables:
CI=true- if the VCR mode is
auto, tests with a missing cassette fail instead of recording
- if the VCR mode is
ACK_DISABLE_VCR=<...>- disables the VCR and falls back to the wrapped live client
*disables all configured VCRs- a comma-separated list disables only matching
vcrNamevalues
When the VCR is disabled, cassette files are not updated.
Common patterns
Build the VCR runtime once
const cassetteStoreLayer = FileSystemCassetteStore.layer().pipe(Layer.provide(NodeServices.layer));
const vcrRuntimeLayer = Layer.mergeAll(
FetchHttpClient.layer,
NodeServices.layer,
cassetteStoreLayer,
);
const vcrLayer = VcrHttpClient.layer({
vcrName: "example",
mode: "auto",
}).pipe(Layer.provide(vcrRuntimeLayer));This keeps the dependency graph explicit:
FileSystemCassetteStore.layer(...)gets its platform services fromNodeServices.layerVcrHttpClient.layer(...)gets its live client,Path.Path, and cassette store fromvcrRuntimeLayer
Override config in tests
import { ConfigProvider, Layer } from "effect";
const vcrTestRuntimeLayer = Layer.mergeAll(
vcrRuntimeLayer,
ConfigProvider.layer(
ConfigProvider.fromUnknown({
CI: false,
ACK_DISABLE_VCR: "",
}),
),
);
const vcrLayer = VcrHttpClient.layer({
vcrName: "example",
mode: "auto",
}).pipe(Layer.provide(vcrTestRuntimeLayer));Use focused subpath imports
import * as VcrHttpClient from "@useairfoil/effect-vcr/vcr-http-client";
import * as FileSystemCassetteStore from "@useairfoil/effect-vcr/file-system-cassette-store";Use root imports for normal package consumption and subpath imports when you want a narrower surface.
