@useairfoil/effect-vcr 0.0.1 → 0.3.0
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 +79 -0
- package/dist/chunk-Bp6m_JJh.js +13 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.js +451 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -7
- package/src/cassette-store.ts +99 -0
- package/src/file-system-cassette-store.ts +79 -0
- package/src/index.ts +4 -0
- package/src/sanitize.ts +168 -0
- package/src/types.ts +81 -0
- package/src/vcr-http-client.ts +413 -0
- package/src/vitest-state.ts +18 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { Config, Effect, Option, Path } from "effect";
|
|
2
|
+
import {
|
|
3
|
+
HttpClient,
|
|
4
|
+
HttpClientError,
|
|
5
|
+
type HttpClientRequest,
|
|
6
|
+
HttpClientResponse,
|
|
7
|
+
} from "effect/unstable/http";
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
Cassette,
|
|
11
|
+
CassetteFile,
|
|
12
|
+
Configuration,
|
|
13
|
+
VcrEntry,
|
|
14
|
+
VcrRequest,
|
|
15
|
+
VcrResponse,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
import { CassetteStore, createEmptyCassette } from "./cassette-store";
|
|
19
|
+
import { buildRequestKey, redactRequest, redactResponse } from "./sanitize";
|
|
20
|
+
import { getVitestState } from "./vitest-state";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Decoder for Uint8Array request bodies when building cassette keys.
|
|
24
|
+
*/
|
|
25
|
+
const decoder = new TextDecoder();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the cassette file name and export key.
|
|
29
|
+
*
|
|
30
|
+
* If explicit cassetteName are provided, use them and default to
|
|
31
|
+
* the "default" export. Otherwise infer from Vitest state:
|
|
32
|
+
* - <file-name>.cassette file name
|
|
33
|
+
* - export key = current test name (describe > test)
|
|
34
|
+
*/
|
|
35
|
+
const resolveCassetteLocation = (config: Configuration) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const path = yield* Path.Path;
|
|
38
|
+
if (config.cassetteName) {
|
|
39
|
+
return {
|
|
40
|
+
name: `${config.cassetteName}.cassette`,
|
|
41
|
+
exportKey: "default",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { testPath, currentTestName } = getVitestState();
|
|
46
|
+
if (!testPath || !currentTestName) {
|
|
47
|
+
throw new Error("VCR cassette path could not be inferred. Provide cassetteName.");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fileName = path.basename(testPath, ".ts");
|
|
51
|
+
const cassetteName = `${fileName}.cassette`;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
name: cassetteName,
|
|
55
|
+
exportKey: currentTestName,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Apply defaults for common VCR behavior while preserving explicit overrides.
|
|
61
|
+
*/
|
|
62
|
+
const normalizeConfig = (config: Configuration) => ({
|
|
63
|
+
vcrName: config.vcrName,
|
|
64
|
+
cassetteName: config.cassetteName,
|
|
65
|
+
mode: config.mode ?? "auto",
|
|
66
|
+
matchIgnore: {
|
|
67
|
+
requestHeaders: ["authorization"],
|
|
68
|
+
...config.matchIgnore,
|
|
69
|
+
},
|
|
70
|
+
redact: {
|
|
71
|
+
requestHeaders: ["authorization"],
|
|
72
|
+
...config.redact,
|
|
73
|
+
},
|
|
74
|
+
match: config.match,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const AckDisableVcrConfig = Config.option(Config.string("ACK_DISABLE_VCR")).pipe(
|
|
78
|
+
Config.map((value) =>
|
|
79
|
+
Option.match(value, {
|
|
80
|
+
onNone: () => new Set<string>(),
|
|
81
|
+
onSome: (raw) =>
|
|
82
|
+
raw === "*"
|
|
83
|
+
? "*"
|
|
84
|
+
: new Set(
|
|
85
|
+
raw
|
|
86
|
+
.split(",")
|
|
87
|
+
.map((segment) => segment.trim().toLowerCase())
|
|
88
|
+
.filter((segment) => segment.length > 0),
|
|
89
|
+
),
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const shouldDisableVcr = (name: string | undefined, disabled: "*" | Set<string>): boolean => {
|
|
95
|
+
if (disabled === "*") return true;
|
|
96
|
+
if (!name) return false;
|
|
97
|
+
const normalized = name.trim().toLowerCase();
|
|
98
|
+
return disabled.has(normalized);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Convert store or replay failures into HttpClient transport errors.
|
|
103
|
+
*/
|
|
104
|
+
const toRequestError = (
|
|
105
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
106
|
+
cause: unknown,
|
|
107
|
+
description?: string,
|
|
108
|
+
) =>
|
|
109
|
+
new HttpClientError.HttpClientError({
|
|
110
|
+
reason: new HttpClientError.TransportError({
|
|
111
|
+
request,
|
|
112
|
+
cause,
|
|
113
|
+
description,
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Convert header map to a plain record for JSON persistence.
|
|
119
|
+
*/
|
|
120
|
+
const recordFromHeaders = (headers: Record<string, string>) => {
|
|
121
|
+
const record: Record<string, string> = {};
|
|
122
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
123
|
+
record[key] = value;
|
|
124
|
+
}
|
|
125
|
+
return record;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Serialize an Effect HttpClientRequest into a cassette request shape.
|
|
130
|
+
*
|
|
131
|
+
* Note: some body variants (Stream, FormData) are not safely serializable
|
|
132
|
+
* without consuming the body, so we store placeholders to avoid breaking
|
|
133
|
+
* live requests.
|
|
134
|
+
*/
|
|
135
|
+
const toVcrRequest = (request: HttpClientRequest.HttpClientRequest): VcrRequest => {
|
|
136
|
+
const headers = recordFromHeaders(request.headers as Record<string, string>);
|
|
137
|
+
const body = (() => {
|
|
138
|
+
switch (request.body._tag) {
|
|
139
|
+
case "Empty":
|
|
140
|
+
return "";
|
|
141
|
+
case "Uint8Array":
|
|
142
|
+
return decoder.decode(request.body.body);
|
|
143
|
+
case "Raw": {
|
|
144
|
+
const raw = request.body.body as unknown;
|
|
145
|
+
if (typeof raw === "string") return raw;
|
|
146
|
+
if (raw instanceof Uint8Array) return decoder.decode(raw);
|
|
147
|
+
try {
|
|
148
|
+
return JSON.stringify(raw);
|
|
149
|
+
} catch {
|
|
150
|
+
return "[raw]";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
case "FormData":
|
|
154
|
+
return "[form-data]";
|
|
155
|
+
case "Stream":
|
|
156
|
+
return "[stream]";
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
method: request.method,
|
|
162
|
+
url: request.url,
|
|
163
|
+
headers,
|
|
164
|
+
body,
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Serialize an Effect HttpClientResponse into a cassette response shape.
|
|
170
|
+
*/
|
|
171
|
+
const toVcrResponse = (
|
|
172
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
173
|
+
body: string,
|
|
174
|
+
): VcrResponse => ({
|
|
175
|
+
status: response.status,
|
|
176
|
+
body,
|
|
177
|
+
headers: recordFromHeaders(response.headers as Record<string, string>),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Load a cassette and map store errors into HttpClient errors.
|
|
182
|
+
*/
|
|
183
|
+
const readCassetteFile = (
|
|
184
|
+
store: CassetteStore,
|
|
185
|
+
name: string,
|
|
186
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
187
|
+
) => store.load(name).pipe(Effect.mapError((error) => toRequestError(request, error)));
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Load a cassette or initialize a new one if missing.
|
|
191
|
+
*/
|
|
192
|
+
const loadOrInitCassetteFile = (
|
|
193
|
+
store: CassetteStore,
|
|
194
|
+
name: string,
|
|
195
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
196
|
+
) => store.loadOrInit(name).pipe(Effect.mapError((error) => toRequestError(request, error)));
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Persist the updated cassette.
|
|
200
|
+
*/
|
|
201
|
+
const saveCassetteFile = (
|
|
202
|
+
store: CassetteStore,
|
|
203
|
+
name: string,
|
|
204
|
+
cassette: CassetteFile,
|
|
205
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
206
|
+
) => store.save(name, cassette).pipe(Effect.mapError((error) => toRequestError(request, error)));
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Load a named cassette export from the file, or return a fresh empty cassette.
|
|
210
|
+
*/
|
|
211
|
+
const readCassetteExport = (
|
|
212
|
+
store: CassetteStore,
|
|
213
|
+
name: string,
|
|
214
|
+
exportKey: string,
|
|
215
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
216
|
+
) =>
|
|
217
|
+
readCassetteFile(store, name, request).pipe(
|
|
218
|
+
Effect.flatMap((file) => {
|
|
219
|
+
const found = file.exports[exportKey];
|
|
220
|
+
if (found) return Effect.succeed(found);
|
|
221
|
+
return createEmptyCassette();
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Locate a cassette entry by custom matcher or stable request key.
|
|
227
|
+
*/
|
|
228
|
+
const findEntry = (
|
|
229
|
+
request: VcrRequest,
|
|
230
|
+
cassette: Cassette,
|
|
231
|
+
config: Configuration,
|
|
232
|
+
): Effect.Effect<VcrEntry | undefined> => {
|
|
233
|
+
if (config.match) {
|
|
234
|
+
return Effect.succeed(
|
|
235
|
+
Object.values(cassette.entries).find((entry) => config.match?.(request, entry)),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return buildRequestKey(request, {
|
|
239
|
+
ignoreHeaders: config.matchIgnore?.requestHeaders,
|
|
240
|
+
ignoreBodyKeys: config.matchIgnore?.requestBodyKeys,
|
|
241
|
+
}).pipe(Effect.map((key) => cassette.entries[key]));
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Replay a response from cassette data using HttpClientResponse.fromWeb.
|
|
246
|
+
*/
|
|
247
|
+
const replay = (
|
|
248
|
+
store: CassetteStore,
|
|
249
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
250
|
+
vcrRequest: VcrRequest,
|
|
251
|
+
config: Configuration,
|
|
252
|
+
name: string,
|
|
253
|
+
exportKey: string,
|
|
254
|
+
) =>
|
|
255
|
+
readCassetteExport(store, name, exportKey, request).pipe(
|
|
256
|
+
Effect.flatMap((cassette) =>
|
|
257
|
+
findEntry(vcrRequest, cassette, config).pipe(
|
|
258
|
+
Effect.flatMap((entry) => {
|
|
259
|
+
if (!entry) {
|
|
260
|
+
return Effect.fail(
|
|
261
|
+
new HttpClientError.HttpClientError({
|
|
262
|
+
reason: new HttpClientError.TransportError({
|
|
263
|
+
request,
|
|
264
|
+
description: `VCR replay missing entry for ${request.method} ${request.url}`,
|
|
265
|
+
}),
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const web = new Response(entry.response.body, {
|
|
271
|
+
status: entry.response.status,
|
|
272
|
+
headers: entry.response.headers,
|
|
273
|
+
});
|
|
274
|
+
return Effect.succeed(HttpClientResponse.fromWeb(request, web));
|
|
275
|
+
}),
|
|
276
|
+
),
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Record a live response into the cassette and return the original response.
|
|
282
|
+
*/
|
|
283
|
+
const record = (
|
|
284
|
+
store: CassetteStore,
|
|
285
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
286
|
+
vcrRequest: VcrRequest,
|
|
287
|
+
effect: Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>,
|
|
288
|
+
config: Configuration,
|
|
289
|
+
name: string,
|
|
290
|
+
exportKey: string,
|
|
291
|
+
) =>
|
|
292
|
+
Effect.gen(function* () {
|
|
293
|
+
// Execute the live request and capture the response body (Effect caches reads).
|
|
294
|
+
const response = yield* effect;
|
|
295
|
+
const body = yield* response.text;
|
|
296
|
+
const vcrResponse = toVcrResponse(response, body);
|
|
297
|
+
|
|
298
|
+
const sanitizedRequest = config.redact
|
|
299
|
+
? redactRequest(vcrRequest, {
|
|
300
|
+
redactHeaders: config.redact.requestHeaders,
|
|
301
|
+
redactBodyKeys: config.redact.requestBodyKeys,
|
|
302
|
+
})
|
|
303
|
+
: vcrRequest;
|
|
304
|
+
const sanitizedResponse = config.redact
|
|
305
|
+
? redactResponse(vcrResponse, {
|
|
306
|
+
redactHeaders: config.redact.responseHeaders,
|
|
307
|
+
redactBodyKeys: config.redact.responseBodyKeys,
|
|
308
|
+
})
|
|
309
|
+
: vcrResponse;
|
|
310
|
+
|
|
311
|
+
// Load or create the cassette before inserting the new entry.
|
|
312
|
+
const file = yield* loadOrInitCassetteFile(store, name, request);
|
|
313
|
+
const cassette = file.exports[exportKey] ?? (yield* createEmptyCassette());
|
|
314
|
+
const key = yield* buildRequestKey(vcrRequest, {
|
|
315
|
+
ignoreHeaders: config.matchIgnore?.requestHeaders,
|
|
316
|
+
ignoreBodyKeys: config.matchIgnore?.requestBodyKeys,
|
|
317
|
+
});
|
|
318
|
+
const next: Cassette = {
|
|
319
|
+
...cassette,
|
|
320
|
+
entries: {
|
|
321
|
+
...cassette.entries,
|
|
322
|
+
[key]: {
|
|
323
|
+
request: sanitizedRequest,
|
|
324
|
+
response: sanitizedResponse,
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
const nextFile: CassetteFile = {
|
|
329
|
+
...file,
|
|
330
|
+
exports: {
|
|
331
|
+
...file.exports,
|
|
332
|
+
[exportKey]: next,
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
// Persist updated cassette to disk.
|
|
336
|
+
yield* saveCassetteFile(store, name, nextFile, request);
|
|
337
|
+
return response;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Build a VCR-aware HttpClient that replays or records per config.
|
|
342
|
+
*/
|
|
343
|
+
const makeVcrHttpClient = (config: Configuration) =>
|
|
344
|
+
Effect.gen(function* () {
|
|
345
|
+
const live = yield* HttpClient.HttpClient;
|
|
346
|
+
const normalized = normalizeConfig(config);
|
|
347
|
+
|
|
348
|
+
const isCi = yield* Config.boolean("CI").pipe(Config.withDefault(false));
|
|
349
|
+
|
|
350
|
+
const disabledVcrs = yield* AckDisableVcrConfig;
|
|
351
|
+
if (shouldDisableVcr(normalized.vcrName, disabledVcrs)) {
|
|
352
|
+
return live;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// CassetteStore is provided via Layer for platform-specific persistence.
|
|
356
|
+
const store = yield* CassetteStore;
|
|
357
|
+
|
|
358
|
+
const { name, exportKey } = yield* resolveCassetteLocation(normalized);
|
|
359
|
+
|
|
360
|
+
return live.pipe(
|
|
361
|
+
HttpClient.transform((effect, request) =>
|
|
362
|
+
Effect.gen(function* () {
|
|
363
|
+
const vcrRequest = toVcrRequest(request);
|
|
364
|
+
if (normalized.mode === "replay") {
|
|
365
|
+
return yield* replay(store, request, vcrRequest, normalized, name, exportKey);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (normalized.mode === "record") {
|
|
369
|
+
return yield* record(store, request, vcrRequest, effect, normalized, name, exportKey);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Auto mode: replay if cassette exists, otherwise record (or fail in CI).
|
|
373
|
+
const available = yield* store
|
|
374
|
+
.exists(name)
|
|
375
|
+
.pipe(Effect.mapError((error) => toRequestError(request, error)));
|
|
376
|
+
|
|
377
|
+
if (!available) {
|
|
378
|
+
if (isCi) {
|
|
379
|
+
return yield* Effect.fail(
|
|
380
|
+
new HttpClientError.HttpClientError({
|
|
381
|
+
reason: new HttpClientError.TransportError({
|
|
382
|
+
request,
|
|
383
|
+
description: "VCR cassette missing in CI for auto mode",
|
|
384
|
+
}),
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return yield* record(store, request, vcrRequest, effect, normalized, name, exportKey);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const cassette = yield* readCassetteExport(store, name, exportKey, request);
|
|
393
|
+
const entry = yield* findEntry(vcrRequest, cassette, normalized);
|
|
394
|
+
|
|
395
|
+
if (entry) {
|
|
396
|
+
const web = new Response(entry.response.body, {
|
|
397
|
+
status: entry.response.status,
|
|
398
|
+
headers: entry.response.headers,
|
|
399
|
+
});
|
|
400
|
+
return HttpClientResponse.fromWeb(request, web);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return yield* record(store, request, vcrRequest, effect, normalized, name, exportKey);
|
|
404
|
+
}),
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Layer that provides a VCR-wrapped HttpClient.
|
|
411
|
+
*/
|
|
412
|
+
export const layer = (config: Configuration = {}) =>
|
|
413
|
+
HttpClient.layerMergedContext(makeVcrHttpClient(config));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type VitestState = {
|
|
2
|
+
readonly testPath?: string;
|
|
3
|
+
readonly currentTestName?: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const getVitestState = (): VitestState => {
|
|
7
|
+
const expectGlobal =
|
|
8
|
+
// biome-ignore lint/suspicious/noExplicitAny: --
|
|
9
|
+
(globalThis as { expect?: { getState?: () => any } }).expect;
|
|
10
|
+
const symbolExpect =
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: --
|
|
12
|
+
(globalThis as { [key: symbol]: { getState?: () => any } })[Symbol.for("expect-global")];
|
|
13
|
+
const state = expectGlobal?.getState?.() ?? symbolExpect?.getState?.();
|
|
14
|
+
return {
|
|
15
|
+
testPath: state?.testPath as string | undefined,
|
|
16
|
+
currentTestName: state?.currentTestName as string | undefined,
|
|
17
|
+
};
|
|
18
|
+
};
|