@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.
- package/README.md +22 -3
- package/dist/testing/index.d.ts +6 -6
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +5 -5
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/node.d.ts +39 -29
- package/dist/testing/node.d.ts.map +1 -1
- package/dist/testing/node.js +39 -29
- package/dist/testing/node.js.map +1 -1
- package/dist/testing/vcr-global-setup.d.ts +12 -0
- package/dist/testing/vcr-global-setup.d.ts.map +1 -1
- package/dist/testing/vcr-global-setup.js +12 -3
- package/dist/testing/vcr-global-setup.js.map +1 -1
- package/dist/testing/vcr-shared.d.ts +24 -6
- package/dist/testing/vcr-shared.d.ts.map +1 -1
- package/dist/testing/vcr-shared.js +19 -6
- package/dist/testing/vcr-shared.js.map +1 -1
- package/dist/testing/vcr-spec.d.ts +10 -1
- package/dist/testing/vcr-spec.d.ts.map +1 -1
- package/dist/testing/vcr-spec.js +40 -8
- package/dist/testing/vcr-spec.js.map +1 -1
- package/dist/testing/vcr-store.d.ts +87 -0
- package/dist/testing/vcr-store.d.ts.map +1 -0
- package/dist/testing/vcr-store.js +192 -0
- package/dist/testing/vcr-store.js.map +1 -0
- package/dist/testing/vcr.d.ts +103 -66
- package/dist/testing/vcr.d.ts.map +1 -1
- package/dist/testing/vcr.js +228 -163
- package/dist/testing/vcr.js.map +1 -1
- package/package.json +13 -13
|
@@ -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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"}
|
package/dist/testing/vcr.d.ts
CHANGED
|
@@ -1,81 +1,118 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
13
|
-
|
|
14
|
-
/**
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
*
|
|
21
|
-
* outbound worker requests out by role:
|
|
84
|
+
* Build the recorder and register it for {@link closeVcr}.
|
|
22
85
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
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
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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":"
|
|
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"}
|