@specific.dev/spectest 0.99.0 → 0.101.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/dist/components/index.d.ts +1 -1
- package/dist/components/replay-key.d.ts +27 -0
- package/dist/components/replay-key.js +96 -0
- package/dist/components/replayFake.d.ts +40 -21
- package/dist/components/replayFake.js +119 -95
- package/package.json +1 -1
- package/src/components/index.ts +6 -1
- package/src/components/replay-key.ts +127 -0
- package/src/components/replayFake-handler.test.ts +217 -0
- package/src/components/replayFake-record.test.ts +156 -0
- package/src/components/replayFake.test.ts +254 -0
- package/src/components/replayFake.ts +161 -116
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// - REPLAY (hermetic): the request is matched against the cassette the
|
|
15
15
|
// test EXPLICITLY loaded (`ctx.fakes.<name>.replay(file)`) and the
|
|
16
16
|
// recorded response is returned. No network. Nothing is auto-loaded:
|
|
17
|
-
// a test that loads no cassette gets a fail-loud 599 on every request.
|
|
17
|
+
// a test that loads no cassette gets a fail-loud 599 on every delegated request.
|
|
18
18
|
// This is what runs under `spectest test`.
|
|
19
19
|
// - RECORD (MITM): the request is forwarded to the REAL host and the
|
|
20
20
|
// request/response pair is captured (decoded, redacted), and the real
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
// recorder (a `spectest test` case), false in eval/manual. So `auto`
|
|
36
36
|
// (the default) replays under test and records during manual use — no new
|
|
37
37
|
// control-plane mode flag. `mode: "record" | "replay"` overrides it.
|
|
38
|
+
// An optional custom handler can return scripted responses or explicitly
|
|
39
|
+
// delegate with ctx.replay(request). Only delegated traffic uses this engine.
|
|
38
40
|
//
|
|
39
41
|
// Credential brokering (per-fake): `inject` rules set
|
|
40
42
|
// headers on the egress forward — overwriting whatever the app sent — so
|
|
@@ -65,6 +67,8 @@ import * as dnsPacket from "dns-packet";
|
|
|
65
67
|
import type { FakeContext, FakeDefinition } from "../index.js";
|
|
66
68
|
import { isRecording } from "../recorder.js";
|
|
67
69
|
import { getRecordSecret } from "../record-secrets.js";
|
|
70
|
+
import { replaySignature, type ReplayKey } from "./replay-key.js";
|
|
71
|
+
export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
|
|
68
72
|
import {
|
|
69
73
|
inferSigV4Scope,
|
|
70
74
|
signAwsV4Request,
|
|
@@ -75,21 +79,6 @@ import {
|
|
|
75
79
|
// Options + cassette format
|
|
76
80
|
// ────────────────────────────────────────────────────────────────────────
|
|
77
81
|
|
|
78
|
-
/** Which parts of a request define a match against the cassette. */
|
|
79
|
-
export interface ReplayMatch {
|
|
80
|
-
/** Match on HTTP method. Default `true`. */
|
|
81
|
-
method?: boolean;
|
|
82
|
-
/** Match on path (no query). Default `true`. */
|
|
83
|
-
path?: boolean;
|
|
84
|
-
/** Match on normalized query string. Default `true`. */
|
|
85
|
-
query?: boolean;
|
|
86
|
-
/** Match on body: `true` = exact decoded body, `"hash"` = sha256 only.
|
|
87
|
-
* Default off. */
|
|
88
|
-
body?: boolean | "hash";
|
|
89
|
-
/** Header names (lowercase) to include in the match. Default none. */
|
|
90
|
-
headers?: string[];
|
|
91
|
-
}
|
|
92
|
-
|
|
93
82
|
/** Match one request dimension. A bare string is an exact match; the
|
|
94
83
|
* object forms cover prefix and regex matching. */
|
|
95
84
|
export type StringMatch =
|
|
@@ -146,7 +135,24 @@ export interface AwsSigV4Sign {
|
|
|
146
135
|
|
|
147
136
|
export type SignConfig = AwsSigV4Sign;
|
|
148
137
|
|
|
149
|
-
|
|
138
|
+
/** A normal fake context with explicit delegation to recording/replay. */
|
|
139
|
+
export interface ReplayFakeContext extends FakeContext {
|
|
140
|
+
/** Record against the real upstream in record mode, or consume a matching
|
|
141
|
+
* cassette interaction in replay mode. A replay miss returns 599; there
|
|
142
|
+
* is no fallback to scripted logic. Inspect a body's clone before calling. */
|
|
143
|
+
replay(request: Request): Promise<Response>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type ReplayHandler<S> = (
|
|
147
|
+
request: Request,
|
|
148
|
+
state: S,
|
|
149
|
+
ctx: ReplayFakeContext,
|
|
150
|
+
) => Response | Promise<Response>;
|
|
151
|
+
|
|
152
|
+
export interface ReplayFakeOptions<
|
|
153
|
+
S = undefined,
|
|
154
|
+
H extends Record<string, unknown> = {},
|
|
155
|
+
> {
|
|
150
156
|
/** Stable name — the `ctx.fakes` key. */
|
|
151
157
|
name: string;
|
|
152
158
|
/** The REAL host to fake, e.g. `"api.stripe.com"`. The fake answers for
|
|
@@ -155,8 +161,12 @@ export interface ReplayFakeOptions {
|
|
|
155
161
|
* resolved via an external DNS server — see the module header). Your
|
|
156
162
|
* app points at this real host directly; no separate stand-in. */
|
|
157
163
|
host: string;
|
|
158
|
-
/**
|
|
159
|
-
|
|
164
|
+
/** Complete request identity. Default: method, path, normalized query,
|
|
165
|
+
* and exact body bytes. The same synchronous, deterministic callback runs
|
|
166
|
+
* on recorded and incoming requests. Return JSON values and/or Uint8Array
|
|
167
|
+
* bytes; objects compare independently of property order. No implicit
|
|
168
|
+
* matching dimensions or fallback apply when a key is supplied. */
|
|
169
|
+
key?: ReplayKey;
|
|
160
170
|
/** Per-fake credential brokering. Rules are evaluated in
|
|
161
171
|
* order; the first whose `match` matches wins (a rule without `match`
|
|
162
172
|
* matches everything and shadows later rules). Applied at the
|
|
@@ -175,6 +185,15 @@ export interface ReplayFakeOptions {
|
|
|
175
185
|
/** `"auto"` (default) records under eval and replays under test;
|
|
176
186
|
* `"record"` / `"replay"` force a mode. */
|
|
177
187
|
mode?: "auto" | "record" | "replay";
|
|
188
|
+
/** Custom state, with the same lifecycle as a normal fake's state. Kept
|
|
189
|
+
* separate from cassette internals; helpers and handler see only S. */
|
|
190
|
+
state?: () => S | Promise<S>;
|
|
191
|
+
/** Custom helpers exposed alongside replay/dump/recorded/count. Those
|
|
192
|
+
* names are reserved. Loading a cassette does not change custom state. */
|
|
193
|
+
helpers?: (args: { name: string; state: S; ctx: FakeContext }) => H | Promise<H>;
|
|
194
|
+
/** Decide whether to answer in code or call ctx.replay(request). Without
|
|
195
|
+
* a handler, every request delegates. Only delegated requests record. */
|
|
196
|
+
handler?: ReplayHandler<S>;
|
|
178
197
|
}
|
|
179
198
|
|
|
180
199
|
export interface CassetteRequest {
|
|
@@ -182,7 +201,8 @@ export interface CassetteRequest {
|
|
|
182
201
|
path: string;
|
|
183
202
|
/** Normalized: keys + repeated values sorted, so matching is order-free. */
|
|
184
203
|
query: Record<string, string[]>;
|
|
185
|
-
/**
|
|
204
|
+
/** Captured non-sensitive headers — never `authorization`/`cookie`.
|
|
205
|
+
* Older cassettes may contain only content-type and selected headers. */
|
|
186
206
|
headers: Record<string, string>;
|
|
187
207
|
bodyHash?: string;
|
|
188
208
|
/** Request body (omitted when empty): decoded + redacted UTF-8 text, or
|
|
@@ -216,6 +236,8 @@ export interface Cassette {
|
|
|
216
236
|
/** Forked-per-test state for one replay fake. */
|
|
217
237
|
export interface CassetteState {
|
|
218
238
|
cassette: Cassette;
|
|
239
|
+
/** Complete signature → cassette interaction indexes, in cassette order. */
|
|
240
|
+
index: Map<string, number[]>;
|
|
219
241
|
/** Per-match-signature replay cursor (call-order sequencing). */
|
|
220
242
|
cursor: Map<string, number>;
|
|
221
243
|
/** Interactions captured this record session. */
|
|
@@ -236,8 +258,9 @@ export interface ReplaySummary {
|
|
|
236
258
|
}
|
|
237
259
|
|
|
238
260
|
export interface ReplayHelpers extends Record<string, unknown> {
|
|
239
|
-
/** Load a cassette into THIS fork's replay set
|
|
261
|
+
/** Load a cassette into THIS fork's replay set for delegated requests,
|
|
240
262
|
* replacing whatever was loaded before and resetting the replay cursor.
|
|
263
|
+
* Does not change custom state or bypass a custom handler.
|
|
241
264
|
* Cassettes are NOT auto-loaded — call this at the top of a test. Pass a
|
|
242
265
|
* path relative to `spectest/tests/` (e.g. `"recordings/stripe.json"`; it
|
|
243
266
|
* must stay under `spectest/tests/`) or a cassette object; a missing file
|
|
@@ -263,7 +286,9 @@ const SENSITIVE_HEADERS = new Set([
|
|
|
263
286
|
"cookie",
|
|
264
287
|
"set-cookie",
|
|
265
288
|
"proxy-authorization",
|
|
289
|
+
"api-key",
|
|
266
290
|
"x-api-key",
|
|
291
|
+
"x-goog-api-key",
|
|
267
292
|
"x-amz-security-token",
|
|
268
293
|
]);
|
|
269
294
|
|
|
@@ -292,17 +317,6 @@ const STRIP_FORWARD_HEADERS = new Set([
|
|
|
292
317
|
const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
|
|
293
318
|
const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
|
|
294
319
|
|
|
295
|
-
function defaultMatch(m: ReplayMatch | undefined): Required<Omit<ReplayMatch, "body" | "headers">> &
|
|
296
|
-
Pick<ReplayMatch, "body" | "headers"> {
|
|
297
|
-
return {
|
|
298
|
-
method: m?.method ?? true,
|
|
299
|
-
path: m?.path ?? true,
|
|
300
|
-
query: m?.query ?? true,
|
|
301
|
-
body: m?.body,
|
|
302
|
-
headers: m?.headers,
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
|
|
306
320
|
/** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
|
|
307
321
|
* in the query for presigned URLs — they change every call and would poison
|
|
308
322
|
* query matching, so they're dropped from both the match key and the forward
|
|
@@ -341,27 +355,6 @@ function recordable(headers: Headers, want: string[]): Record<string, string> {
|
|
|
341
355
|
return out;
|
|
342
356
|
}
|
|
343
357
|
|
|
344
|
-
/** Canonical match signature for an interaction's request, used as both
|
|
345
|
-
* the replay-cursor key and the equality basis (two requests match iff
|
|
346
|
-
* their signatures are equal). Computed identically for an incoming
|
|
347
|
-
* request and a stored `CassetteRequest`. */
|
|
348
|
-
function signature(req: CassetteRequest, match: ReturnType<typeof defaultMatch>): string {
|
|
349
|
-
const sig: Record<string, unknown> = {};
|
|
350
|
-
if (match.method) sig.method = req.method.toUpperCase();
|
|
351
|
-
if (match.path) sig.path = req.path;
|
|
352
|
-
if (match.query) sig.query = req.query;
|
|
353
|
-
if (match.body === true) sig.body = req.body ?? "";
|
|
354
|
-
else if (match.body === "hash") sig.bodyHash = req.bodyHash ?? "";
|
|
355
|
-
if (match.headers && match.headers.length > 0) {
|
|
356
|
-
const h: Record<string, string> = {};
|
|
357
|
-
for (const name of match.headers.map((n) => n.toLowerCase()).sort()) {
|
|
358
|
-
h[name] = req.headers[name] ?? "";
|
|
359
|
-
}
|
|
360
|
-
sig.headers = h;
|
|
361
|
-
}
|
|
362
|
-
return JSON.stringify(sig);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
358
|
/** The project's `spectest/tests/` dir — cassettes live alongside the
|
|
366
359
|
* tests, and a `replay(path)` argument is a path relative to here. Keeping
|
|
367
360
|
* them under `tests/` means re-recording is picked up on the next run with
|
|
@@ -386,7 +379,7 @@ function coerceCassette(parsed: Cassette, name: string, host: string): Cassette
|
|
|
386
379
|
version: 1,
|
|
387
380
|
fake: parsed.fake ?? name,
|
|
388
381
|
host: parsed.host ?? host,
|
|
389
|
-
interactions: parsed.interactions,
|
|
382
|
+
interactions: structuredClone(parsed.interactions),
|
|
390
383
|
};
|
|
391
384
|
}
|
|
392
385
|
|
|
@@ -421,7 +414,7 @@ function readCassetteFile(name: string, file: string, host: string): Cassette {
|
|
|
421
414
|
/** Decode response bytes to a string, falling back to base64 for binary. */
|
|
422
415
|
function decodeBody(bytes: Uint8Array): { body: string; bodyEncoding: "utf8" | "base64" } {
|
|
423
416
|
try {
|
|
424
|
-
const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
|
|
417
|
+
const text = new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
425
418
|
return { body: text, bodyEncoding: "utf8" };
|
|
426
419
|
} catch {
|
|
427
420
|
return { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" };
|
|
@@ -663,15 +656,20 @@ function forwardToRealUpstream(args: {
|
|
|
663
656
|
// replayFake
|
|
664
657
|
// ────────────────────────────────────────────────────────────────────────
|
|
665
658
|
|
|
666
|
-
export function replayFake(
|
|
667
|
-
opts: ReplayFakeOptions,
|
|
668
|
-
): FakeDefinition<CassetteState, ReplayHelpers> {
|
|
659
|
+
export function replayFake<S = undefined, H extends Record<string, unknown> = {}>(
|
|
660
|
+
opts: ReplayFakeOptions<S, H>,
|
|
661
|
+
): FakeDefinition<CassetteState & { custom: S }, ReplayHelpers & H> {
|
|
669
662
|
if (!opts.name) throw new Error("replayFake: `name` is required");
|
|
670
663
|
if (!opts.host || typeof opts.host !== "string") {
|
|
671
664
|
throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
|
|
672
665
|
}
|
|
673
666
|
const host = opts.host.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
674
|
-
|
|
667
|
+
if ("match" in opts) {
|
|
668
|
+
throw new Error(`replayFake(${opts.name}): \`match\` has been replaced by \`key(request)\`, which defines the complete request identity`);
|
|
669
|
+
}
|
|
670
|
+
if (opts.key !== undefined && typeof opts.key !== "function") {
|
|
671
|
+
throw new Error(`replayFake(${opts.name}): \`key\` must be a synchronous function`);
|
|
672
|
+
}
|
|
675
673
|
const injectRules = opts.inject ?? [];
|
|
676
674
|
// Refs every header template references — the control plane resolves these
|
|
677
675
|
// server-side and pushes them for the environment lifetime (see the daemon's
|
|
@@ -685,19 +683,33 @@ export function replayFake(
|
|
|
685
683
|
};
|
|
686
684
|
|
|
687
685
|
// ── REPLAY ──────────────────────────────────────────────────────────
|
|
686
|
+
function signature(request: CassetteRequest, location: string): string {
|
|
687
|
+
try {
|
|
688
|
+
return replaySignature(request, opts.key);
|
|
689
|
+
} catch (error) {
|
|
690
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
691
|
+
throw new Error(`replayFake(${opts.name}): invalid replay key for ${location}: ${message}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
688
695
|
function replay(reqLike: CassetteRequest, state: CassetteState): Response {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
696
|
+
let sig: string;
|
|
697
|
+
try {
|
|
698
|
+
sig = signature(reqLike, "incoming request");
|
|
699
|
+
} catch (error) {
|
|
700
|
+
return new Response((error as Error).message + "\n", {
|
|
701
|
+
status: 599, headers: { "content-type": "text/plain" },
|
|
702
|
+
});
|
|
693
703
|
}
|
|
704
|
+
const matches = state.index.get(sig) ?? [];
|
|
694
705
|
const idx = state.cursor.get(sig) ?? 0;
|
|
695
|
-
const
|
|
706
|
+
const interactionIndex = matches[idx];
|
|
707
|
+
const hit = interactionIndex === undefined ? undefined : state.cassette.interactions[interactionIndex];
|
|
696
708
|
if (!hit) {
|
|
697
709
|
const body =
|
|
698
710
|
`replayFake(${opts.name}): no cassette interaction for ` +
|
|
699
|
-
`${reqLike.method} ${reqLike.path} (matched ${matches.length}, wanted #${idx + 1}). ` +
|
|
700
|
-
`
|
|
711
|
+
`${reqLike.method} ${reqLike.path} (${matches.length === 0 ? "unknown key" : "exhausted key"}; matched ${matches.length}, wanted #${idx + 1}). ` +
|
|
712
|
+
`Check \`key\` or re-record the cassette.\n`;
|
|
701
713
|
return new Response(body, {
|
|
702
714
|
status: 599,
|
|
703
715
|
headers: { "content-type": "text/plain" },
|
|
@@ -883,7 +895,7 @@ export function replayFake(
|
|
|
883
895
|
method: reqLike.method,
|
|
884
896
|
path: reqLike.path,
|
|
885
897
|
query: redactedQuery,
|
|
886
|
-
headers: reqLike.headers,
|
|
898
|
+
headers: Object.fromEntries(Object.entries(reqLike.headers).map(([k, v]) => [k, redact(v)])),
|
|
887
899
|
...(reqLike.body !== undefined
|
|
888
900
|
? {
|
|
889
901
|
bodyHash: reqLike.bodyHash,
|
|
@@ -931,6 +943,9 @@ export function replayFake(
|
|
|
931
943
|
state: CassetteState,
|
|
932
944
|
_ctx: FakeContext,
|
|
933
945
|
): Promise<Response> => {
|
|
946
|
+
if (req.bodyUsed) {
|
|
947
|
+
throw new Error(`replayFake(${opts.name}): cannot delegate a consumed request body; inspect request.clone() before calling ctx.replay(request)`);
|
|
948
|
+
}
|
|
934
949
|
const url = new URL(req.url);
|
|
935
950
|
const bodyBytes =
|
|
936
951
|
req.method === "GET" || req.method === "HEAD"
|
|
@@ -943,7 +958,7 @@ export function replayFake(
|
|
|
943
958
|
// Strip SigV4 `x-amz-*` query params from the match key when signing —
|
|
944
959
|
// presigned URLs carry a per-request signature there.
|
|
945
960
|
query: normalizeQuery(url.searchParams, opts.sign !== undefined),
|
|
946
|
-
headers: recordable(req.headers, [
|
|
961
|
+
headers: recordable(req.headers, [...req.headers.keys()]),
|
|
947
962
|
...(decodedBody
|
|
948
963
|
? {
|
|
949
964
|
bodyHash: sha256Bytes(bodyBytes),
|
|
@@ -957,58 +972,88 @@ export function replayFake(
|
|
|
957
972
|
return record(req, url, reqLike, bodyBytes, state);
|
|
958
973
|
};
|
|
959
974
|
|
|
960
|
-
const
|
|
975
|
+
const replayHelpers = (state: CassetteState): ReplayHelpers => ({
|
|
976
|
+
replay(file: string | Cassette): ReplaySummary {
|
|
977
|
+
let loaded: Cassette;
|
|
978
|
+
let label: string;
|
|
979
|
+
if (file && typeof file === "object") {
|
|
980
|
+
loaded = coerceCassette(file, opts.name, host);
|
|
981
|
+
label = file.fake || "(inline)";
|
|
982
|
+
} else if (typeof file === "string" && file.length > 0) {
|
|
983
|
+
label = file;
|
|
984
|
+
loaded = readCassetteFile(opts.name, file, host);
|
|
985
|
+
} else {
|
|
986
|
+
throw new Error(
|
|
987
|
+
`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
|
|
988
|
+
`(e.g. "recordings/${opts.name}.json").`,
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
// Build/validate before replacing live state: a bad key/cassette
|
|
992
|
+
// must leave the previous cassette and consumption cursors intact.
|
|
993
|
+
const index = new Map<string, number[]>();
|
|
994
|
+
for (const [i, interaction] of loaded.interactions.entries()) {
|
|
995
|
+
const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
|
|
996
|
+
const queue = index.get(sig);
|
|
997
|
+
if (queue) queue.push(i);
|
|
998
|
+
else index.set(sig, [i]);
|
|
999
|
+
}
|
|
1000
|
+
// Mutate the live state object (the handler reads the same
|
|
1001
|
+
// reference); reset the cursor so replay starts from the top.
|
|
1002
|
+
state.cassette = loaded;
|
|
1003
|
+
state.index = index;
|
|
1004
|
+
state.cursor = new Map();
|
|
1005
|
+
return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
|
|
1006
|
+
},
|
|
1007
|
+
dump(): Cassette {
|
|
1008
|
+
return structuredClone({
|
|
1009
|
+
version: 1,
|
|
1010
|
+
fake: opts.name,
|
|
1011
|
+
host,
|
|
1012
|
+
interactions: [...state.cassette.interactions, ...state.recorded],
|
|
1013
|
+
});
|
|
1014
|
+
},
|
|
1015
|
+
recorded(): CassetteInteraction[] {
|
|
1016
|
+
return structuredClone(state.recorded);
|
|
1017
|
+
},
|
|
1018
|
+
count(): number {
|
|
1019
|
+
return state.recorded.length;
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
const initialState = (custom: S): CassetteState & { custom: S } => ({
|
|
1024
|
+
cassette: emptyCassette(opts.name, host),
|
|
1025
|
+
index: new Map(),
|
|
1026
|
+
cursor: new Map(),
|
|
1027
|
+
recorded: [],
|
|
1028
|
+
dirty: false,
|
|
1029
|
+
custom,
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
const def: FakeDefinition<CassetteState & { custom: S }, ReplayHelpers & H> = {
|
|
961
1033
|
name: opts.name,
|
|
962
|
-
// Fake = real: we answer for the real host on :80 and :443.
|
|
963
1034
|
hostnames: [host],
|
|
964
1035
|
secretRefs,
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
} else if (typeof file === "string" && file.length > 0) {
|
|
983
|
-
label = file;
|
|
984
|
-
loaded = readCassetteFile(opts.name, file, host);
|
|
985
|
-
} else {
|
|
986
|
-
throw new Error(
|
|
987
|
-
`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
|
|
988
|
-
`(e.g. "recordings/${opts.name}.json").`,
|
|
989
|
-
);
|
|
1036
|
+
state: () => opts.state
|
|
1037
|
+
? Promise.resolve(opts.state()).then(initialState)
|
|
1038
|
+
: initialState(undefined as S),
|
|
1039
|
+
handler: (request, state, ctx) => opts.handler
|
|
1040
|
+
? opts.handler(request, state.custom, {
|
|
1041
|
+
...ctx,
|
|
1042
|
+
replay: (delegated) => handler(delegated, state, ctx),
|
|
1043
|
+
})
|
|
1044
|
+
: handler(request, state, ctx),
|
|
1045
|
+
helpers: ({ name, state, ctx }) => {
|
|
1046
|
+
const builtins = replayHelpers(state);
|
|
1047
|
+
if (!opts.helpers) return builtins as ReplayHelpers & H;
|
|
1048
|
+
return Promise.resolve(opts.helpers({ name, state: state.custom, ctx })).then((custom) => {
|
|
1049
|
+
for (const key of Object.keys(builtins)) {
|
|
1050
|
+
if (key in custom) {
|
|
1051
|
+
throw new Error(`replayFake(${opts.name}): custom helper ${JSON.stringify(key)} is reserved for recording/replay; choose another name`);
|
|
1052
|
+
}
|
|
990
1053
|
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
state.cursor = new Map();
|
|
995
|
-
return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
|
|
996
|
-
},
|
|
997
|
-
dump(): Cassette {
|
|
998
|
-
return {
|
|
999
|
-
version: 1,
|
|
1000
|
-
fake: opts.name,
|
|
1001
|
-
host,
|
|
1002
|
-
interactions: [...state.cassette.interactions, ...state.recorded],
|
|
1003
|
-
};
|
|
1004
|
-
},
|
|
1005
|
-
recorded(): CassetteInteraction[] {
|
|
1006
|
-
return state.recorded.slice();
|
|
1007
|
-
},
|
|
1008
|
-
count(): number {
|
|
1009
|
-
return state.recorded.length;
|
|
1010
|
-
},
|
|
1011
|
-
}),
|
|
1054
|
+
return { ...custom, ...builtins };
|
|
1055
|
+
});
|
|
1056
|
+
},
|
|
1012
1057
|
};
|
|
1013
1058
|
return def;
|
|
1014
1059
|
}
|