@loopingai/core 0.2.0 → 0.3.1

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.
@@ -0,0 +1,87 @@
1
+ /** A stored request. `body` is utf-8 text; binary request bodies are not supported. */
2
+ export interface RecordedRequest {
3
+ method: string;
4
+ url: string;
5
+ headers: Record<string, string | string[]>;
6
+ body: string;
7
+ }
8
+ /** A stored response. `body` is base64, so a binary payload survives the file. */
9
+ export interface RecordedResponse {
10
+ statusCode: number;
11
+ headers: Record<string, string | string[]>;
12
+ body: string;
13
+ trailers?: Record<string, string>;
14
+ }
15
+ /**
16
+ * One cassette entry: a request and every response recorded for it, in order.
17
+ *
18
+ * The array is what makes a replayed sequence work — two identical requests
19
+ * that returned different things (an ARC frame after a move, say) are one entry
20
+ * with two responses, walked in order. See {@link Cassette.match}.
21
+ */
22
+ export interface CassetteEntry {
23
+ request: RecordedRequest;
24
+ responses: RecordedResponse[];
25
+ }
26
+ /** What {@link Cassette.match} hands back: everything needed to build a Response. */
27
+ export interface ReplayableResponse {
28
+ status: number;
29
+ headers: [string, string][];
30
+ body: Uint8Array;
31
+ }
32
+ export interface CassetteOptions {
33
+ /** Request *and* response headers never written to the file (API keys, auth). */
34
+ excludeHeaders?: string[];
35
+ }
36
+ /** `${METHOD}\n${url}\n${sha256(body)}` — the whole matching rule. */
37
+ export declare function requestKey(method: string, url: string, body: string): string;
38
+ /**
39
+ * Turn a stored response into the pieces a `Response` is built from: body
40
+ * decoded from base64, headers flattened (a repeated header such as
41
+ * `set-cookie` becomes one entry per value) and {@link HOP_BY_HOP} dropped.
42
+ *
43
+ * Used on both paths, so a response served straight after recording is framed
44
+ * exactly as the same response will be on replay.
45
+ */
46
+ export declare function replayable(response: RecordedResponse): ReplayableResponse;
47
+ /**
48
+ * One cassette file, loaded lazily and written only when it has changed.
49
+ *
50
+ * Playback is **read-only**: nothing here mutates on a replay, which is what
51
+ * stops a plain `npm test` from dirtying committed cassettes. The sequence
52
+ * counter ({@link #calls}) is in memory and dies with the process, deliberately
53
+ * — `SnapshotAgent` persisted its equivalent and re-saved on close, so every
54
+ * run left every cassette modified in git.
55
+ */
56
+ export declare class Cassette {
57
+ #private;
58
+ constructor(file: string, options?: CassetteOptions);
59
+ get file(): string;
60
+ /** Every request in the file, as `METHOD url`, for error messages. */
61
+ get recorded(): string[];
62
+ /**
63
+ * Read the file into memory, keying each entry by its own request rather than
64
+ * trusting anything stored alongside it.
65
+ *
66
+ * Two entries that key the same are a mistake rather than a merge: a request
67
+ * that was issued twice belongs in one entry with two `responses`, which is
68
+ * what {@link match} walks. Saying so here turns a hand-edit slip into a named
69
+ * error instead of a replay that silently serves the wrong response second.
70
+ */
71
+ load(): void;
72
+ /**
73
+ * The next recorded response for this request, or `null` if it was never
74
+ * recorded.
75
+ *
76
+ * Repeated identical requests walk `responses` in order and then **hold on
77
+ * the last one**, so a recording that captured a single response replays it
78
+ * for every call — which is what a poll loop needs when it runs a different
79
+ * number of times on replay than it did while recording.
80
+ */
81
+ match(method: string, url: string, body: string): ReplayableResponse | null;
82
+ /** Append a live response. Repeats of a request extend its `responses`. */
83
+ record(request: RecordedRequest, response: RecordedResponse): void;
84
+ /** Write if anything was recorded since the last flush. A no-op in playback. */
85
+ flush(): void;
86
+ }
87
+ //# sourceMappingURL=vcr-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vcr-store.d.ts","sourceRoot":"","sources":["../../src/testing/vcr-store.ts"],"names":[],"mappings":"AAgCA,uFAAuF;AACvF,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC3C,IAAI,EAAE,MAAM,CAAC;CACd;AAED,kFAAkF;AAClF,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACnC;AAED;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,eAAe,CAAC;IACzB,SAAS,EAAE,gBAAgB,EAAE,CAAC;CAC/B;AAsBD,qFAAqF;AACrF,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;IAC5B,IAAI,EAAE,UAAU,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,iFAAiF;IACjF,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,sEAAsE;AACtE,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAG5E;AAaD;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE,gBAAgB,GAAG,kBAAkB,CAazE;AAED;;;;;;;;GAQG;AACH,qBAAa,QAAQ;;gBASP,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB;IAOvD,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,sEAAsE;IACtE,IAAI,QAAQ,IAAI,MAAM,EAAE,CAIvB;IAED;;;;;;;;OAQG;IACH,IAAI,IAAI,IAAI;IAoBZ;;;;;;;;OAQG;IACH,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,IAAI;IAW3E,2EAA2E;IAC3E,MAAM,CAAC,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,gBAAgB,GAAG,IAAI;IAqBlE,gFAAgF;IAChF,KAAK,IAAI,IAAI;CAOd"}
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Cassette load / match / write. **Node realm** (`node:fs`, `node:crypto`), no
3
+ * network and no `undici` — the recorder that drives this lives in `vcr.ts`.
4
+ *
5
+ * ## Why this replaced undici's `SnapshotAgent`
6
+ *
7
+ * `SnapshotAgent` keys every entry on a hash of method + URL + *every
8
+ * non-excluded request header*. In a Worker test those headers are runtime
9
+ * artifacts: a cassette recorded through the pool carries `user-agent: undici`,
10
+ * `cf-worker: vitest-pool-workers-runner-.example.com`, `sec-fetch-mode` and
11
+ * `accept-encoding`. Bump miniflare or workerd, any one of them changes, and
12
+ * every entry in every committed cassette misses at once — a whole suite going
13
+ * red with `No snapshot found` and nothing to point at.
14
+ *
15
+ * So the key here is **method + URL + request body**, and nothing else. Headers
16
+ * are still stored (minus secrets, see {@link CassetteOptions.excludeHeaders})
17
+ * because they are useful to read in a diff, but they cannot break a replay.
18
+ *
19
+ * The on-disk format owes `SnapshotAgent` nothing: a flat array of
20
+ * {@link CassetteEntry}. Cassettes it wrote are not readable, and deliberately
21
+ * so — its recorder keyed on `String(opts.body)`, and a Worker's POST body
22
+ * reaches a dispatcher as a `ReadableStream`, so every streamed request it ever
23
+ * captured stored the literal text `[object ReadableStream]` where the payload
24
+ * belonged. Those bodies are not recoverable from the file, and a reader that
25
+ * papered over it would have to match such entries on method + URL alone —
26
+ * quietly reintroducing the ambiguity this store exists to remove. Re-record
27
+ * instead.
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
31
+ import path from "node:path";
32
+ /**
33
+ * Headers that describe how a body was framed on the wire, which is not how it
34
+ * is framed on replay: bodies are stored decoded and are handed back whole, so
35
+ * replaying `transfer-encoding: chunked` or a stale `content-length` mis-frames
36
+ * the response, and `content-encoding: gzip` makes the consumer try to inflate
37
+ * plaintext. Stripped on the way out, kept in the file.
38
+ */
39
+ const HOP_BY_HOP = new Set([
40
+ "connection",
41
+ "content-encoding",
42
+ "content-length",
43
+ "keep-alive",
44
+ "proxy-authenticate",
45
+ "proxy-authorization",
46
+ "te",
47
+ "trailer",
48
+ "transfer-encoding",
49
+ "upgrade"
50
+ ]);
51
+ /** `${METHOD}\n${url}\n${sha256(body)}` — the whole matching rule. */
52
+ export function requestKey(method, url, body) {
53
+ const digest = createHash("sha256").update(body, "utf8").digest("base64url");
54
+ return `${method.toUpperCase()}\n${url}\n${digest}`;
55
+ }
56
+ function filterHeaders(headers, exclude) {
57
+ const kept = {};
58
+ for (const [name, value] of Object.entries(headers)) {
59
+ if (!exclude.has(name.toLowerCase()))
60
+ kept[name.toLowerCase()] = value;
61
+ }
62
+ return kept;
63
+ }
64
+ /**
65
+ * Turn a stored response into the pieces a `Response` is built from: body
66
+ * decoded from base64, headers flattened (a repeated header such as
67
+ * `set-cookie` becomes one entry per value) and {@link HOP_BY_HOP} dropped.
68
+ *
69
+ * Used on both paths, so a response served straight after recording is framed
70
+ * exactly as the same response will be on replay.
71
+ */
72
+ export function replayable(response) {
73
+ const headers = [];
74
+ for (const [name, value] of Object.entries(response.headers)) {
75
+ if (HOP_BY_HOP.has(name.toLowerCase()))
76
+ continue;
77
+ for (const one of Array.isArray(value) ? value : [value]) {
78
+ headers.push([name, one]);
79
+ }
80
+ }
81
+ return {
82
+ status: response.statusCode,
83
+ headers,
84
+ body: Buffer.from(response.body, "base64")
85
+ };
86
+ }
87
+ /**
88
+ * One cassette file, loaded lazily and written only when it has changed.
89
+ *
90
+ * Playback is **read-only**: nothing here mutates on a replay, which is what
91
+ * stops a plain `npm test` from dirtying committed cassettes. The sequence
92
+ * counter ({@link #calls}) is in memory and dies with the process, deliberately
93
+ * — `SnapshotAgent` persisted its equivalent and re-saved on close, so every
94
+ * run left every cassette modified in git.
95
+ */
96
+ export class Cassette {
97
+ #file;
98
+ #exclude;
99
+ /** Insertion-ordered, keyed by {@link requestKey}. */
100
+ #entries = new Map();
101
+ /** How many times each key has been replayed *this run*. Never persisted. */
102
+ #calls = new Map();
103
+ #dirty = false;
104
+ constructor(file, options = {}) {
105
+ this.#file = file;
106
+ this.#exclude = new Set((options.excludeHeaders ?? []).map((h) => h.toLowerCase()));
107
+ }
108
+ get file() {
109
+ return this.#file;
110
+ }
111
+ /** Every request in the file, as `METHOD url`, for error messages. */
112
+ get recorded() {
113
+ return [...this.#entries.values()].map((e) => `${e.request.method} ${e.request.url}`);
114
+ }
115
+ /**
116
+ * Read the file into memory, keying each entry by its own request rather than
117
+ * trusting anything stored alongside it.
118
+ *
119
+ * Two entries that key the same are a mistake rather than a merge: a request
120
+ * that was issued twice belongs in one entry with two `responses`, which is
121
+ * what {@link match} walks. Saying so here turns a hand-edit slip into a named
122
+ * error instead of a replay that silently serves the wrong response second.
123
+ */
124
+ load() {
125
+ if (!existsSync(this.#file))
126
+ return;
127
+ const raw = JSON.parse(readFileSync(this.#file, "utf8"));
128
+ if (!Array.isArray(raw)) {
129
+ throw new Error(`VCR cassette ${this.#file} is not a JSON array`);
130
+ }
131
+ for (const entry of raw) {
132
+ const { request, responses } = entry;
133
+ const key = requestKey(request.method, request.url, request.body ?? "");
134
+ if (this.#entries.has(key)) {
135
+ throw new Error(`VCR cassette ${path.basename(this.#file)} has two entries for ` +
136
+ `${request.method} ${request.url} with the same body. Put the ` +
137
+ `responses in one entry's \`responses\` array instead.`);
138
+ }
139
+ this.#entries.set(key, { request, responses: [...responses] });
140
+ }
141
+ }
142
+ /**
143
+ * The next recorded response for this request, or `null` if it was never
144
+ * recorded.
145
+ *
146
+ * Repeated identical requests walk `responses` in order and then **hold on
147
+ * the last one**, so a recording that captured a single response replays it
148
+ * for every call — which is what a poll loop needs when it runs a different
149
+ * number of times on replay than it did while recording.
150
+ */
151
+ match(method, url, body) {
152
+ const key = requestKey(method, url, body);
153
+ const entry = this.#entries.get(key);
154
+ if (!entry)
155
+ return null;
156
+ const call = this.#calls.get(key) ?? 0;
157
+ this.#calls.set(key, call + 1);
158
+ return replayable(entry.responses[Math.min(call, entry.responses.length - 1)]);
159
+ }
160
+ /** Append a live response. Repeats of a request extend its `responses`. */
161
+ record(request, response) {
162
+ const key = requestKey(request.method, request.url, request.body);
163
+ const stored = {
164
+ ...response,
165
+ headers: filterHeaders(response.headers, this.#exclude)
166
+ };
167
+ const entry = this.#entries.get(key);
168
+ if (entry) {
169
+ entry.responses.push(stored);
170
+ }
171
+ else {
172
+ this.#entries.set(key, {
173
+ request: {
174
+ ...request,
175
+ headers: filterHeaders(request.headers, this.#exclude)
176
+ },
177
+ responses: [stored]
178
+ });
179
+ }
180
+ this.#dirty = true;
181
+ }
182
+ /** Write if anything was recorded since the last flush. A no-op in playback. */
183
+ flush() {
184
+ if (!this.#dirty)
185
+ return;
186
+ mkdirSync(path.dirname(this.#file), { recursive: true });
187
+ const entries = [...this.#entries.values()];
188
+ writeFileSync(this.#file, `${JSON.stringify(entries, null, 2)}\n`);
189
+ this.#dirty = false;
190
+ }
191
+ }
192
+ //# sourceMappingURL=vcr-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vcr-store.js","sourceRoot":"","sources":["../../src/testing/vcr-store.ts"],"names":[],"mappings":"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"}
@@ -1,81 +1,118 @@
1
- import { MockAgent, type Dispatcher } from "undici";
2
- export interface CreateVcrAgentOptions {
3
- /** Absolute path of the cassettes directory (test/snapshots/). */
1
+ /**
2
+ * The **Node realm** half of the VCR harness: a Miniflare `outboundService`.
3
+ *
4
+ * ## Why `outboundService` and not `fetchMock`
5
+ *
6
+ * Miniflare 4 implemented `fetchMock` as one line of sugar over this same hook —
7
+ * `outboundService = (req) => fetch(req, { dispatcher: fetchMock })` — and
8
+ * Miniflare 5 dropped `fetchMock` entirely, keeping only `outboundService`.
9
+ * `@cloudflare/vitest-pool-workers` 0.20 removed it from its override options to
10
+ * match, saying so in as many words: *"`fetchMock`: you should use
11
+ * `outboundService` instead"*. Targeting the hook rather than the sugar is what
12
+ * lets one harness serve pool 0.18 through 0.20+.
13
+ *
14
+ * It also drops two constraints that had nothing to do with recording:
15
+ *
16
+ * - **No `undici` peer.** `fetchMock` was validated with
17
+ * `z.instanceof(MockAgent)` against *Miniflare's own* undici, so a second copy
18
+ * in `node_modules` failed with `Input not instance of MockAgent` and the peer
19
+ * range had to track Miniflare's exact pin. `outboundService` has no
20
+ * `instanceof` check in either direction — a foreign `Response` is re-wrapped.
21
+ * - **No silent no-op.** An unknown key in the `miniflare` options is ignored,
22
+ * which is how a harness wired to `fetchMock` on pool 0.20 failed: every
23
+ * request escaped to the real network and died as `internal error;
24
+ * reference = …`, naming nothing. `setupRecording()` now proves the recorder
25
+ * answered before a test runs — see {@link VCR_MARKER_HEADER}.
26
+ *
27
+ * ## How a request is routed
28
+ *
29
+ * Every outbound `fetch()` from workerd arrives here as a standard `Request`,
30
+ * with the true target URL already restored by Miniflare, and is dispatched on
31
+ * host alone:
32
+ *
33
+ * - **The control channel** ({@link VCR_CONTROL_ORIGIN}) — specs run in workerd
34
+ * and have no filesystem, so an in-band `fetch` is the only way to say which
35
+ * cassette the current test is using. See `setupRecording` (vcr-spec.ts).
36
+ * - **A {@link VcrOptions.handlers} host** — a consumer-supplied stub, for
37
+ * things that are fixtures rather than recordings (a gateway JWKS).
38
+ * - **An {@link VcrOptions.allowNetworkHosts} host** — the real network, always.
39
+ * - **Anything else, cassette active** — recorded or replayed against it.
40
+ * - **Anything else, no cassette** — blocked. A test that was never wired for
41
+ * recording cannot reach the network by accident.
42
+ */
43
+ /** Minimal shape of the `Request` Miniflare hands an outbound service. */
44
+ export interface VcrRequestHeaders {
45
+ forEach(callback: (value: string, name: string) => void): void;
46
+ get(name: string): string | null;
47
+ }
48
+ /** Minimal shape of the `Request` Miniflare hands an outbound service. */
49
+ export interface VcrRequest {
50
+ readonly url: string;
51
+ readonly method: string;
52
+ readonly headers: VcrRequestHeaders;
53
+ arrayBuffer(): Promise<ArrayBuffer>;
54
+ }
55
+ /**
56
+ * Structural on purpose: core does not depend on `miniflare`, and this type
57
+ * assigns to `WorkerOptions["outboundService"]` under both Miniflare 4 and 5.
58
+ */
59
+ export type VcrOutboundService = (request: VcrRequest) => Promise<Response>;
60
+ export interface VcrOptions {
61
+ /** Absolute path of the cassettes directory. */
4
62
  snapshotsDir: string;
5
63
  /**
6
- * true → every activated cassette records real traffic, unconditionally
7
- * overwriting whatever was there (undici's `"record"` mode never loads or
8
- * matches existing entries, unlike `"update"` see {@link VcrAgent.agentFor}).
9
- * false → replay.
64
+ * true → every activated cassette captures live traffic, replacing whatever
65
+ * was there. Recording never loads the existing file first, so a recording run
66
+ * always reflects the live API rather than half of a stale one.
67
+ * false → replay, and no request ever leaves the machine.
10
68
  */
11
69
  record: boolean;
12
- /** Hosts served by ordinary MockAgent interceptors, never VCR'd (the gateway JWKS). */
13
- passthroughHosts: string[];
14
- /** Request headers never written to a cassette (secrets: API keys, auth). */
70
+ /** Hosts answered by a stub instead of a cassette (e.g. a gateway JWKS). */
71
+ handlers?: Record<string, (request: VcrRequest) => Response | Promise<Response>>;
72
+ /** Hosts that always reach the real network, recorded or not. */
73
+ allowNetworkHosts?: string[];
74
+ /** Request *and* response headers never written to a cassette (API keys, auth). */
15
75
  excludeHeaders?: string[];
16
- /** Headers stored but excluded from request matching (e.g. session cookies). */
17
- ignoreHeaders?: string[];
76
+ }
77
+ export interface Vcr {
78
+ /** Hand this to `cloudflareTest({ miniflare: { outboundService } })`. */
79
+ readonly outboundService: VcrOutboundService;
80
+ /** Flush every cassette. Safe to call more than once. */
81
+ close(): Promise<void>;
18
82
  }
19
83
  /**
20
- * The single agent Miniflare accepts as `fetchMock` (it allows only one), fanning
21
- * outbound worker requests out by role:
84
+ * Build the recorder and register it for {@link closeVcr}.
22
85
  *
23
- * - **Control channel** ({@link VCR_CONTROL_ORIGIN}) → ordinary MockAgent
24
- * interceptors whose reply callbacks flip the *active cassette*. This is how a
25
- * spec running in workerd (no filesystem) tells this Node-side agent which
26
- * test is running — see `setupRecording` (vcr-spec.ts).
27
- * - **Passthrough hosts** (the gateway JWKS) → ordinary MockAgent interceptors,
28
- * unchanged from before VCR existed.
29
- * - **Everything else, while a cassette is active** → that cassette's undici
30
- * {@link SnapshotAgent} record/replay. In playback a request with no matching
31
- * recording throws `No snapshot found` — offline, never the network.
32
- * - **Everything else, with no active cassette** → ordinary MockAgent behavior
33
- * (`disableNetConnect()` → offline error). A non-recorded test never reaches
34
- * the network.
86
+ * ```ts
87
+ * // vitest.config.ts
88
+ * const vcr = createVcr({
89
+ * snapshotsDir: path.resolve(import.meta.dirname, "test/snapshots"),
90
+ * record: recordFromEnv(),
91
+ * excludeHeaders: ["x-api-key", "authorization", "cookie", "set-cookie"]
92
+ * });
35
93
  *
36
- * There is **no registry**: cassettes are keyed per test (file + describe + test
37
- * name) by the spec helper, activated one at a time over the control channel. A
38
- * new recorded spec touches only its own `.spec` file.
94
+ * export default defineConfig({
95
+ * plugins: [cloudflareTest({ miniflare: { outboundService: vcr.outboundService } })],
96
+ * test: { globalSetup: ["@loopingai/core/testing/vcr-global-setup"] }
97
+ * });
98
+ * ```
99
+ */
100
+ export declare function createVcr(options: VcrOptions): Vcr;
101
+ /**
102
+ * Whether this run records, from `RECORD`.
39
103
  *
40
- * Why `VcrAgent extends MockAgent` rather than `SnapshotAgent`: a `SnapshotAgent`'s
41
- * `dispatch` fully overrides `MockAgent`'s (it never falls back to the interceptor
42
- * path), so one `SnapshotAgent` cannot also serve the control/interceptor fixtures
43
- * and cannot serve more than one cassette. Keeping one internal `SnapshotAgent`
44
- * per cassette and dispatching to the active one, with an ordinary
45
- * `MockAgent.dispatch` fallback for control/passthrough/offline, leaves the
46
- * interceptor path exactly as it always worked. Miniflare validates `fetchMock`
47
- * with `instanceof MockAgent`, which `VcrAgent` satisfies directly.
104
+ * Compared against `"1"` rather than tested for truthiness, because every
105
+ * non-empty string is truthy: `RECORD=0` and `RECORD=false` the two things
106
+ * someone reaches for to turn recording *off* would otherwise turn it on and
107
+ * overwrite committed cassettes with live traffic. The opposite mistake
108
+ * (`RECORD=true` not recording) fails loudly on the next assertion and costs
109
+ * nothing.
48
110
  */
49
- export declare class VcrAgent extends MockAgent {
50
- #private;
51
- constructor(options: CreateVcrAgentOptions);
52
- dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean;
53
- /**
54
- * Closes every cassette's `SnapshotAgent`, then this agent.
55
- *
56
- * Record mode: `SnapshotAgent.close()` saves each cassette, stops its
57
- * recorder's auto-flush timer, and closes its real sockets — all required (see
58
- * {@link closeVcr}). We then {@link stripVolatileFields} from each saved file
59
- * so the committed cassette carries no `callCount`/`timestamp`.
60
- *
61
- * Playback mode: undici's `SnapshotAgent.close()` *unconditionally* re-saves
62
- * the cassette, writing back the `callCount` its recorder mutates on every
63
- * replay — the side effect that dirtied cassettes on a plain `npm test`. There
64
- * is nothing to save and no real socket in playback, so we skip that save
65
- * entirely and only stop each recorder's timers.
66
- */
67
- close(): Promise<void>;
68
- }
69
- export declare function createVcrAgent(options: CreateVcrAgentOptions): VcrAgent;
111
+ export declare function recordFromEnv(): boolean;
70
112
  /**
71
- * Flush and close the active VCR agent (all its cassettes). In record mode,
72
- * undici's recorder arms a self-refreshing, non-`unref`ed auto-flush timer and
73
- * the real `SnapshotAgent` keeps sockets open both keep the process alive
74
- * after tests finish and trip Vitest's "close timed out" at teardown. Calling
75
- * this (from a Vitest `globalSetup` teardown, which runs before Vite closes its
76
- * own server) stops them. A no-op if no agent was created, and in playback only
77
- * stops recorder timers — {@link VcrAgent.close} deliberately does not re-save
78
- * the cassette there (see its doc).
113
+ * Flush every cassette. Cassettes are already written when each one is
114
+ * released, so this is a safety net for a run that ended without releasing —
115
+ * and a no-op if no recorder was created.
79
116
  */
80
117
  export declare function closeVcr(): Promise<void>;
81
118
  //# sourceMappingURL=vcr.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"vcr.d.ts","sourceRoot":"","sources":["../../src/testing/vcr.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAiB,KAAK,UAAU,EAAE,MAAM,QAAQ,CAAC;AAmCnE,MAAM,WAAW,qBAAqB;IACpC,kEAAkE;IAClE,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB,uFAAuF;IACvF,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,6EAA6E;IAC7E,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,gFAAgF;IAChF,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,qBAAa,QAAS,SAAQ,SAAS;;gBAWzB,OAAO,EAAE,qBAAqB;IA6E1C,QAAQ,CACN,IAAI,EAAE,UAAU,CAAC,eAAe,EAChC,OAAO,EAAE,UAAU,CAAC,eAAe,GAClC,OAAO;IAWV;;;;;;;;;;;;;OAaG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAW7B;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,QAAQ,CAIvE;AAOD;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAM9C"}
1
+ {"version":3,"file":"vcr.d.ts","sourceRoot":"","sources":["../../src/testing/vcr.ts"],"names":[],"mappings":"AAeA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,0EAA0E;AAC1E,MAAM,WAAW,iBAAiB;IAChC,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAC/D,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,0EAA0E;AAC1E,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,iBAAiB,CAAC;IACpC,WAAW,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;CACrC;AAED;;;GAGG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,EAAE,UAAU,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE5E,MAAM,WAAW,UAAU;IACzB,gDAAgD;IAChD,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,CACf,MAAM,EACN,CAAC,OAAO,EAAE,UAAU,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CACtD,CAAC;IACF,iEAAiE;IACjE,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B,mFAAmF;IACnF,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,GAAG;IAClB,yEAAyE;IACzE,QAAQ,CAAC,eAAe,EAAE,kBAAkB,CAAC;IAC7C,yDAAyD;IACzD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAgPD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,UAAU,GAAG,GAAG,CAQlD;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAOD;;;;GAIG;AACH,wBAAsB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAM9C"}