@specific.dev/spectest 0.99.0 → 0.100.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 +12 -17
- package/dist/components/replayFake.js +51 -50
- package/package.json +1 -1
- package/src/components/index.ts +4 -1
- package/src/components/replay-key.ts +127 -0
- package/src/components/replayFake-record.test.ts +114 -0
- package/src/components/replayFake.test.ts +254 -0
- package/src/components/replayFake.ts +59 -65
|
@@ -13,6 +13,6 @@ export { github, type GithubOptions } from "./github.js";
|
|
|
13
13
|
export { apple, type AppleOptions } from "./apple.js";
|
|
14
14
|
export { microsoft, type MicrosoftOptions } from "./microsoft.js";
|
|
15
15
|
export { okta, type OktaOptions } from "./okta.js";
|
|
16
|
-
export { replayFake, type ReplayFakeOptions, type
|
|
16
|
+
export { replayFake, type ReplayFakeOptions, type ReplayKey, type ReplayRequest, type MatchKey, type JsonValue, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
|
|
17
17
|
export { electron, type ElectronOptions } from "./electron.js";
|
|
18
18
|
export { android, type AndroidOptions } from "./android.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CassetteRequest } from "./replayFake.js";
|
|
2
|
+
export type JsonValue = null | boolean | number | string | readonly JsonValue[] | {
|
|
3
|
+
readonly [key: string]: JsonValue;
|
|
4
|
+
};
|
|
5
|
+
/** Identity content. Objects compare independently of property order; arrays
|
|
6
|
+
* preserve order. Bytes are distinct from strings, arrays, and objects. */
|
|
7
|
+
export type MatchKey = null | boolean | number | string | Uint8Array | readonly MatchKey[] | {
|
|
8
|
+
readonly [key: string]: MatchKey;
|
|
9
|
+
};
|
|
10
|
+
/** A buffered request view shared by incoming and recorded requests.
|
|
11
|
+
* Headers are lowercase and exclude credentials. Recorded values may be
|
|
12
|
+
* redacted; choose identity fields that survive recording. */
|
|
13
|
+
export interface ReplayRequest {
|
|
14
|
+
readonly method: string;
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly query: Readonly<Record<string, readonly string[]>>;
|
|
17
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
18
|
+
/** Decode UTF-8, throwing on invalid text. */
|
|
19
|
+
text(): string;
|
|
20
|
+
/** Parse JSON. The type parameter is not runtime validation. */
|
|
21
|
+
json<T = JsonValue>(): T;
|
|
22
|
+
/** Return an independent copy of the body bytes. */
|
|
23
|
+
bytes(): Uint8Array;
|
|
24
|
+
}
|
|
25
|
+
export type ReplayKey = (request: ReplayRequest) => MatchKey;
|
|
26
|
+
/** Internal only; the SDK exports the request/key types, not this function. */
|
|
27
|
+
export declare function replaySignature(request: CassetteRequest, key?: ReplayKey): string;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** Stream a typed, length-delimited canonical representation into the hash.
|
|
3
|
+
* Never call toJSON: unsupported values must fail rather than silently lose
|
|
4
|
+
* identity fields. In particular, an async callback must not become `{}`. */
|
|
5
|
+
function hashKey(key) {
|
|
6
|
+
const hash = createHash("sha256");
|
|
7
|
+
const ancestors = new Set();
|
|
8
|
+
const visit = (value) => {
|
|
9
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
10
|
+
hash.update(JSON.stringify(value));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
14
|
+
hash.update(JSON.stringify(value));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (value instanceof Uint8Array) {
|
|
18
|
+
hash.update(`bytes:${value.byteLength}:`);
|
|
19
|
+
hash.update(value);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (value instanceof Promise) {
|
|
23
|
+
// Report the invalid callback synchronously without leaving a rejected
|
|
24
|
+
// async callback to become an unrelated unhandled-rejection failure.
|
|
25
|
+
void value.catch(() => { });
|
|
26
|
+
throw new Error("key callbacks must be synchronous, not return promises");
|
|
27
|
+
}
|
|
28
|
+
if (typeof value !== "object" || value === null) {
|
|
29
|
+
throw new Error("key must contain only JSON values or Uint8Array bytes (no undefined, functions, or non-finite numbers)");
|
|
30
|
+
}
|
|
31
|
+
if (ancestors.has(value))
|
|
32
|
+
throw new Error("key must not contain cycles");
|
|
33
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
34
|
+
throw new Error("key must not contain symbol properties");
|
|
35
|
+
}
|
|
36
|
+
ancestors.add(value);
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
if (Object.keys(value).length !== value.length) {
|
|
39
|
+
throw new Error("key arrays must be dense and have no extra properties");
|
|
40
|
+
}
|
|
41
|
+
hash.update("[");
|
|
42
|
+
for (let i = 0; i < value.length; i++) {
|
|
43
|
+
if (!Object.hasOwn(value, i))
|
|
44
|
+
throw new Error("key arrays must be dense");
|
|
45
|
+
visit(value[i]);
|
|
46
|
+
hash.update(",");
|
|
47
|
+
}
|
|
48
|
+
hash.update("]");
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const proto = Object.getPrototypeOf(value);
|
|
52
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
53
|
+
throw new Error("key must use plain objects, not promises, dates, maps, or class instances; callbacks must be synchronous");
|
|
54
|
+
}
|
|
55
|
+
hash.update("{");
|
|
56
|
+
for (const name of Object.keys(value).sort()) {
|
|
57
|
+
hash.update(JSON.stringify(name));
|
|
58
|
+
hash.update(":");
|
|
59
|
+
visit(value[name]);
|
|
60
|
+
hash.update(",");
|
|
61
|
+
}
|
|
62
|
+
hash.update("}");
|
|
63
|
+
}
|
|
64
|
+
ancestors.delete(value);
|
|
65
|
+
};
|
|
66
|
+
visit(key);
|
|
67
|
+
return hash.digest("hex");
|
|
68
|
+
}
|
|
69
|
+
function bodyBytes(request) {
|
|
70
|
+
return Buffer.from(request.body ?? "", request.bodyEncoding === "base64" ? "base64" : "utf8");
|
|
71
|
+
}
|
|
72
|
+
function requestView(request) {
|
|
73
|
+
let bytes;
|
|
74
|
+
let text;
|
|
75
|
+
const readBytes = () => bytes ??= bodyBytes(request);
|
|
76
|
+
const readText = () => text ??= new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readBytes());
|
|
77
|
+
const query = Object.fromEntries(Object.entries(request.query).map(([k, v]) => [k, Object.freeze([...v])]));
|
|
78
|
+
return Object.freeze({
|
|
79
|
+
method: request.method.toUpperCase(),
|
|
80
|
+
path: request.path,
|
|
81
|
+
query: Object.freeze(query),
|
|
82
|
+
headers: Object.freeze({ ...request.headers }),
|
|
83
|
+
text: readText,
|
|
84
|
+
json: () => JSON.parse(readText()),
|
|
85
|
+
bytes: () => Uint8Array.from(readBytes()),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** Internal only; the SDK exports the request/key types, not this function. */
|
|
89
|
+
export function replaySignature(request, key) {
|
|
90
|
+
if (key)
|
|
91
|
+
return hashKey(key(requestView(request)));
|
|
92
|
+
// Existing cassettes retain the original byte hash even when their stored
|
|
93
|
+
// text was redacted. Older/hand-written cassettes may only have the body.
|
|
94
|
+
const body = request.bodyHash ?? `sha256:${createHash("sha256").update(bodyBytes(request)).digest("hex")}`;
|
|
95
|
+
return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query, body });
|
|
96
|
+
}
|
|
@@ -1,18 +1,6 @@
|
|
|
1
1
|
import type { FakeDefinition } from "../index.js";
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
/** Match on HTTP method. Default `true`. */
|
|
5
|
-
method?: boolean;
|
|
6
|
-
/** Match on path (no query). Default `true`. */
|
|
7
|
-
path?: boolean;
|
|
8
|
-
/** Match on normalized query string. Default `true`. */
|
|
9
|
-
query?: boolean;
|
|
10
|
-
/** Match on body: `true` = exact decoded body, `"hash"` = sha256 only.
|
|
11
|
-
* Default off. */
|
|
12
|
-
body?: boolean | "hash";
|
|
13
|
-
/** Header names (lowercase) to include in the match. Default none. */
|
|
14
|
-
headers?: string[];
|
|
15
|
-
}
|
|
2
|
+
import { type ReplayKey } from "./replay-key.js";
|
|
3
|
+
export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
|
|
16
4
|
/** Match one request dimension. A bare string is an exact match; the
|
|
17
5
|
* object forms cover prefix and regex matching. */
|
|
18
6
|
export type StringMatch = string | {
|
|
@@ -75,8 +63,12 @@ export interface ReplayFakeOptions {
|
|
|
75
63
|
* resolved via an external DNS server — see the module header). Your
|
|
76
64
|
* app points at this real host directly; no separate stand-in. */
|
|
77
65
|
host: string;
|
|
78
|
-
/**
|
|
79
|
-
|
|
66
|
+
/** Complete request identity. Default: method, path, normalized query,
|
|
67
|
+
* and exact body bytes. The same synchronous, deterministic callback runs
|
|
68
|
+
* on recorded and incoming requests. Return JSON values and/or Uint8Array
|
|
69
|
+
* bytes; objects compare independently of property order. No implicit
|
|
70
|
+
* matching dimensions or fallback apply when a key is supplied. */
|
|
71
|
+
key?: ReplayKey;
|
|
80
72
|
/** Per-fake credential brokering. Rules are evaluated in
|
|
81
73
|
* order; the first whose `match` matches wins (a rule without `match`
|
|
82
74
|
* matches everything and shadows later rules). Applied at the
|
|
@@ -101,7 +93,8 @@ export interface CassetteRequest {
|
|
|
101
93
|
path: string;
|
|
102
94
|
/** Normalized: keys + repeated values sorted, so matching is order-free. */
|
|
103
95
|
query: Record<string, string[]>;
|
|
104
|
-
/**
|
|
96
|
+
/** Captured non-sensitive headers — never `authorization`/`cookie`.
|
|
97
|
+
* Older cassettes may contain only content-type and selected headers. */
|
|
105
98
|
headers: Record<string, string>;
|
|
106
99
|
bodyHash?: string;
|
|
107
100
|
/** Request body (omitted when empty): decoded + redacted UTF-8 text, or
|
|
@@ -131,6 +124,8 @@ export interface Cassette {
|
|
|
131
124
|
/** Forked-per-test state for one replay fake. */
|
|
132
125
|
export interface CassetteState {
|
|
133
126
|
cassette: Cassette;
|
|
127
|
+
/** Complete signature → cassette interaction indexes, in cassette order. */
|
|
128
|
+
index: Map<string, number[]>;
|
|
134
129
|
/** Per-match-signature replay cursor (call-order sequencing). */
|
|
135
130
|
cursor: Map<string, number>;
|
|
136
131
|
/** Interactions captured this record session. */
|
|
@@ -62,6 +62,7 @@ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
|
62
62
|
import * as dnsPacket from "dns-packet";
|
|
63
63
|
import { isRecording } from "../recorder.js";
|
|
64
64
|
import { getRecordSecret } from "../record-secrets.js";
|
|
65
|
+
import { replaySignature } from "./replay-key.js";
|
|
65
66
|
import { inferSigV4Scope, signAwsV4Request, } from "../aws-sigv4.js";
|
|
66
67
|
// ────────────────────────────────────────────────────────────────────────
|
|
67
68
|
// Helpers
|
|
@@ -71,7 +72,9 @@ const SENSITIVE_HEADERS = new Set([
|
|
|
71
72
|
"cookie",
|
|
72
73
|
"set-cookie",
|
|
73
74
|
"proxy-authorization",
|
|
75
|
+
"api-key",
|
|
74
76
|
"x-api-key",
|
|
77
|
+
"x-goog-api-key",
|
|
75
78
|
"x-amz-security-token",
|
|
76
79
|
]);
|
|
77
80
|
/** Hop-by-hop + body-framing headers never forwarded / never replayed
|
|
@@ -97,15 +100,6 @@ const STRIP_FORWARD_HEADERS = new Set([
|
|
|
97
100
|
* resolver (which points the faked host back at the daemon). */
|
|
98
101
|
const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
|
|
99
102
|
const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
|
|
100
|
-
function defaultMatch(m) {
|
|
101
|
-
return {
|
|
102
|
-
method: m?.method ?? true,
|
|
103
|
-
path: m?.path ?? true,
|
|
104
|
-
query: m?.query ?? true,
|
|
105
|
-
body: m?.body,
|
|
106
|
-
headers: m?.headers,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
103
|
/** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
|
|
110
104
|
* in the query for presigned URLs — they change every call and would poison
|
|
111
105
|
* query matching, so they're dropped from both the match key and the forward
|
|
@@ -144,31 +138,6 @@ function recordable(headers, want) {
|
|
|
144
138
|
}
|
|
145
139
|
return out;
|
|
146
140
|
}
|
|
147
|
-
/** Canonical match signature for an interaction's request, used as both
|
|
148
|
-
* the replay-cursor key and the equality basis (two requests match iff
|
|
149
|
-
* their signatures are equal). Computed identically for an incoming
|
|
150
|
-
* request and a stored `CassetteRequest`. */
|
|
151
|
-
function signature(req, match) {
|
|
152
|
-
const sig = {};
|
|
153
|
-
if (match.method)
|
|
154
|
-
sig.method = req.method.toUpperCase();
|
|
155
|
-
if (match.path)
|
|
156
|
-
sig.path = req.path;
|
|
157
|
-
if (match.query)
|
|
158
|
-
sig.query = req.query;
|
|
159
|
-
if (match.body === true)
|
|
160
|
-
sig.body = req.body ?? "";
|
|
161
|
-
else if (match.body === "hash")
|
|
162
|
-
sig.bodyHash = req.bodyHash ?? "";
|
|
163
|
-
if (match.headers && match.headers.length > 0) {
|
|
164
|
-
const h = {};
|
|
165
|
-
for (const name of match.headers.map((n) => n.toLowerCase()).sort()) {
|
|
166
|
-
h[name] = req.headers[name] ?? "";
|
|
167
|
-
}
|
|
168
|
-
sig.headers = h;
|
|
169
|
-
}
|
|
170
|
-
return JSON.stringify(sig);
|
|
171
|
-
}
|
|
172
141
|
/** The project's `spectest/tests/` dir — cassettes live alongside the
|
|
173
142
|
* tests, and a `replay(path)` argument is a path relative to here. Keeping
|
|
174
143
|
* them under `tests/` means re-recording is picked up on the next run with
|
|
@@ -190,7 +159,7 @@ function coerceCassette(parsed, name, host) {
|
|
|
190
159
|
version: 1,
|
|
191
160
|
fake: parsed.fake ?? name,
|
|
192
161
|
host: parsed.host ?? host,
|
|
193
|
-
interactions: parsed.interactions,
|
|
162
|
+
interactions: structuredClone(parsed.interactions),
|
|
194
163
|
};
|
|
195
164
|
}
|
|
196
165
|
/** Read a cassette by its path relative to `spectest/tests/` (e.g.
|
|
@@ -218,7 +187,7 @@ function readCassetteFile(name, file, host) {
|
|
|
218
187
|
/** Decode response bytes to a string, falling back to base64 for binary. */
|
|
219
188
|
function decodeBody(bytes) {
|
|
220
189
|
try {
|
|
221
|
-
const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
|
|
190
|
+
const text = new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
222
191
|
return { body: text, bodyEncoding: "utf8" };
|
|
223
192
|
}
|
|
224
193
|
catch {
|
|
@@ -434,7 +403,12 @@ export function replayFake(opts) {
|
|
|
434
403
|
throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
|
|
435
404
|
}
|
|
436
405
|
const host = opts.host.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
437
|
-
|
|
406
|
+
if ("match" in opts) {
|
|
407
|
+
throw new Error(`replayFake(${opts.name}): \`match\` has been replaced by \`key(request)\`, which defines the complete request identity`);
|
|
408
|
+
}
|
|
409
|
+
if (opts.key !== undefined && typeof opts.key !== "function") {
|
|
410
|
+
throw new Error(`replayFake(${opts.name}): \`key\` must be a synchronous function`);
|
|
411
|
+
}
|
|
438
412
|
const injectRules = opts.inject ?? [];
|
|
439
413
|
// Refs every header template references — the control plane resolves these
|
|
440
414
|
// server-side and pushes them for the environment lifetime (see the daemon's
|
|
@@ -448,19 +422,33 @@ export function replayFake(opts) {
|
|
|
448
422
|
return isRecording() ? "replay" : "record";
|
|
449
423
|
};
|
|
450
424
|
// ── REPLAY ──────────────────────────────────────────────────────────
|
|
425
|
+
function signature(request, location) {
|
|
426
|
+
try {
|
|
427
|
+
return replaySignature(request, opts.key);
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
431
|
+
throw new Error(`replayFake(${opts.name}): invalid replay key for ${location}: ${message}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
451
434
|
function replay(reqLike, state) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
435
|
+
let sig;
|
|
436
|
+
try {
|
|
437
|
+
sig = signature(reqLike, "incoming request");
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
return new Response(error.message + "\n", {
|
|
441
|
+
status: 599, headers: { "content-type": "text/plain" },
|
|
442
|
+
});
|
|
457
443
|
}
|
|
444
|
+
const matches = state.index.get(sig) ?? [];
|
|
458
445
|
const idx = state.cursor.get(sig) ?? 0;
|
|
459
|
-
const
|
|
446
|
+
const interactionIndex = matches[idx];
|
|
447
|
+
const hit = interactionIndex === undefined ? undefined : state.cassette.interactions[interactionIndex];
|
|
460
448
|
if (!hit) {
|
|
461
449
|
const body = `replayFake(${opts.name}): no cassette interaction for ` +
|
|
462
|
-
`${reqLike.method} ${reqLike.path} (matched ${matches.length}, wanted #${idx + 1}). ` +
|
|
463
|
-
`
|
|
450
|
+
`${reqLike.method} ${reqLike.path} (${matches.length === 0 ? "unknown key" : "exhausted key"}; matched ${matches.length}, wanted #${idx + 1}). ` +
|
|
451
|
+
`Check \`key\` or re-record the cassette.\n`;
|
|
464
452
|
return new Response(body, {
|
|
465
453
|
status: 599,
|
|
466
454
|
headers: { "content-type": "text/plain" },
|
|
@@ -618,7 +606,7 @@ export function replayFake(opts) {
|
|
|
618
606
|
method: reqLike.method,
|
|
619
607
|
path: reqLike.path,
|
|
620
608
|
query: redactedQuery,
|
|
621
|
-
headers: reqLike.headers,
|
|
609
|
+
headers: Object.fromEntries(Object.entries(reqLike.headers).map(([k, v]) => [k, redact(v)])),
|
|
622
610
|
...(reqLike.body !== undefined
|
|
623
611
|
? {
|
|
624
612
|
bodyHash: reqLike.bodyHash,
|
|
@@ -667,7 +655,7 @@ export function replayFake(opts) {
|
|
|
667
655
|
// Strip SigV4 `x-amz-*` query params from the match key when signing —
|
|
668
656
|
// presigned URLs carry a per-request signature there.
|
|
669
657
|
query: normalizeQuery(url.searchParams, opts.sign !== undefined),
|
|
670
|
-
headers: recordable(req.headers, [
|
|
658
|
+
headers: recordable(req.headers, [...req.headers.keys()]),
|
|
671
659
|
...(decodedBody
|
|
672
660
|
? {
|
|
673
661
|
bodyHash: sha256Bytes(bodyBytes),
|
|
@@ -690,6 +678,7 @@ export function replayFake(opts) {
|
|
|
690
678
|
// `replay(...)` to populate it.
|
|
691
679
|
state: () => ({
|
|
692
680
|
cassette: emptyCassette(opts.name, host),
|
|
681
|
+
index: new Map(),
|
|
693
682
|
cursor: new Map(),
|
|
694
683
|
recorded: [],
|
|
695
684
|
dirty: false,
|
|
@@ -711,22 +700,34 @@ export function replayFake(opts) {
|
|
|
711
700
|
throw new Error(`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
|
|
712
701
|
`(e.g. "recordings/${opts.name}.json").`);
|
|
713
702
|
}
|
|
703
|
+
// Build/validate before replacing live state: a bad key/cassette
|
|
704
|
+
// must leave the previous cassette and consumption cursors intact.
|
|
705
|
+
const index = new Map();
|
|
706
|
+
for (const [i, interaction] of loaded.interactions.entries()) {
|
|
707
|
+
const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
|
|
708
|
+
const queue = index.get(sig);
|
|
709
|
+
if (queue)
|
|
710
|
+
queue.push(i);
|
|
711
|
+
else
|
|
712
|
+
index.set(sig, [i]);
|
|
713
|
+
}
|
|
714
714
|
// Mutate the live state object (the handler reads the same
|
|
715
715
|
// reference); reset the cursor so replay starts from the top.
|
|
716
716
|
state.cassette = loaded;
|
|
717
|
+
state.index = index;
|
|
717
718
|
state.cursor = new Map();
|
|
718
719
|
return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
|
|
719
720
|
},
|
|
720
721
|
dump() {
|
|
721
|
-
return {
|
|
722
|
+
return structuredClone({
|
|
722
723
|
version: 1,
|
|
723
724
|
fake: opts.name,
|
|
724
725
|
host,
|
|
725
726
|
interactions: [...state.cassette.interactions, ...state.recorded],
|
|
726
|
-
};
|
|
727
|
+
});
|
|
727
728
|
},
|
|
728
729
|
recorded() {
|
|
729
|
-
return state.recorded
|
|
730
|
+
return structuredClone(state.recorded);
|
|
730
731
|
},
|
|
731
732
|
count() {
|
|
732
733
|
return state.recorded.length;
|
package/package.json
CHANGED
package/src/components/index.ts
CHANGED
|
@@ -85,7 +85,10 @@ export { okta, type OktaOptions } from "./okta.js";
|
|
|
85
85
|
export {
|
|
86
86
|
replayFake,
|
|
87
87
|
type ReplayFakeOptions,
|
|
88
|
-
type
|
|
88
|
+
type ReplayKey,
|
|
89
|
+
type ReplayRequest,
|
|
90
|
+
type MatchKey,
|
|
91
|
+
type JsonValue,
|
|
89
92
|
type InjectRule,
|
|
90
93
|
type InjectMatch,
|
|
91
94
|
type StringMatch,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { CassetteRequest } from "./replayFake.js";
|
|
3
|
+
|
|
4
|
+
export type JsonValue =
|
|
5
|
+
| null | boolean | number | string
|
|
6
|
+
| readonly JsonValue[]
|
|
7
|
+
| { readonly [key: string]: JsonValue };
|
|
8
|
+
|
|
9
|
+
/** Identity content. Objects compare independently of property order; arrays
|
|
10
|
+
* preserve order. Bytes are distinct from strings, arrays, and objects. */
|
|
11
|
+
export type MatchKey =
|
|
12
|
+
| null | boolean | number | string | Uint8Array
|
|
13
|
+
| readonly MatchKey[]
|
|
14
|
+
| { readonly [key: string]: MatchKey };
|
|
15
|
+
|
|
16
|
+
/** A buffered request view shared by incoming and recorded requests.
|
|
17
|
+
* Headers are lowercase and exclude credentials. Recorded values may be
|
|
18
|
+
* redacted; choose identity fields that survive recording. */
|
|
19
|
+
export interface ReplayRequest {
|
|
20
|
+
readonly method: string;
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly query: Readonly<Record<string, readonly string[]>>;
|
|
23
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
24
|
+
/** Decode UTF-8, throwing on invalid text. */
|
|
25
|
+
text(): string;
|
|
26
|
+
/** Parse JSON. The type parameter is not runtime validation. */
|
|
27
|
+
json<T = JsonValue>(): T;
|
|
28
|
+
/** Return an independent copy of the body bytes. */
|
|
29
|
+
bytes(): Uint8Array;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ReplayKey = (request: ReplayRequest) => MatchKey;
|
|
33
|
+
|
|
34
|
+
/** Stream a typed, length-delimited canonical representation into the hash.
|
|
35
|
+
* Never call toJSON: unsupported values must fail rather than silently lose
|
|
36
|
+
* identity fields. In particular, an async callback must not become `{}`. */
|
|
37
|
+
function hashKey(key: MatchKey): string {
|
|
38
|
+
const hash = createHash("sha256");
|
|
39
|
+
const ancestors = new Set<object>();
|
|
40
|
+
const visit = (value: unknown): void => {
|
|
41
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
42
|
+
hash.update(JSON.stringify(value));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
46
|
+
hash.update(JSON.stringify(value));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (value instanceof Uint8Array) {
|
|
50
|
+
hash.update(`bytes:${value.byteLength}:`);
|
|
51
|
+
hash.update(value);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (value instanceof Promise) {
|
|
55
|
+
// Report the invalid callback synchronously without leaving a rejected
|
|
56
|
+
// async callback to become an unrelated unhandled-rejection failure.
|
|
57
|
+
void value.catch(() => {});
|
|
58
|
+
throw new Error("key callbacks must be synchronous, not return promises");
|
|
59
|
+
}
|
|
60
|
+
if (typeof value !== "object" || value === null) {
|
|
61
|
+
throw new Error("key must contain only JSON values or Uint8Array bytes (no undefined, functions, or non-finite numbers)");
|
|
62
|
+
}
|
|
63
|
+
if (ancestors.has(value)) throw new Error("key must not contain cycles");
|
|
64
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
65
|
+
throw new Error("key must not contain symbol properties");
|
|
66
|
+
}
|
|
67
|
+
ancestors.add(value);
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
if (Object.keys(value).length !== value.length) {
|
|
70
|
+
throw new Error("key arrays must be dense and have no extra properties");
|
|
71
|
+
}
|
|
72
|
+
hash.update("[");
|
|
73
|
+
for (let i = 0; i < value.length; i++) {
|
|
74
|
+
if (!Object.hasOwn(value, i)) throw new Error("key arrays must be dense");
|
|
75
|
+
visit(value[i]);
|
|
76
|
+
hash.update(",");
|
|
77
|
+
}
|
|
78
|
+
hash.update("]");
|
|
79
|
+
} else {
|
|
80
|
+
const proto = Object.getPrototypeOf(value);
|
|
81
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
82
|
+
throw new Error("key must use plain objects, not promises, dates, maps, or class instances; callbacks must be synchronous");
|
|
83
|
+
}
|
|
84
|
+
hash.update("{");
|
|
85
|
+
for (const name of Object.keys(value).sort()) {
|
|
86
|
+
hash.update(JSON.stringify(name));
|
|
87
|
+
hash.update(":");
|
|
88
|
+
visit((value as Record<string, unknown>)[name]);
|
|
89
|
+
hash.update(",");
|
|
90
|
+
}
|
|
91
|
+
hash.update("}");
|
|
92
|
+
}
|
|
93
|
+
ancestors.delete(value);
|
|
94
|
+
};
|
|
95
|
+
visit(key);
|
|
96
|
+
return hash.digest("hex");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function bodyBytes(request: CassetteRequest): Uint8Array {
|
|
100
|
+
return Buffer.from(request.body ?? "", request.bodyEncoding === "base64" ? "base64" : "utf8");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function requestView(request: CassetteRequest): ReplayRequest {
|
|
104
|
+
let bytes: Uint8Array | undefined;
|
|
105
|
+
let text: string | undefined;
|
|
106
|
+
const readBytes = () => bytes ??= bodyBytes(request);
|
|
107
|
+
const readText = () => text ??= new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readBytes());
|
|
108
|
+
const query = Object.fromEntries(Object.entries(request.query).map(([k, v]) => [k, Object.freeze([...v])]));
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
method: request.method.toUpperCase(),
|
|
111
|
+
path: request.path,
|
|
112
|
+
query: Object.freeze(query),
|
|
113
|
+
headers: Object.freeze({ ...request.headers }),
|
|
114
|
+
text: readText,
|
|
115
|
+
json: <T = JsonValue>(): T => JSON.parse(readText()) as T,
|
|
116
|
+
bytes: () => Uint8Array.from(readBytes()),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Internal only; the SDK exports the request/key types, not this function. */
|
|
121
|
+
export function replaySignature(request: CassetteRequest, key?: ReplayKey): string {
|
|
122
|
+
if (key) return hashKey(key(requestView(request)));
|
|
123
|
+
// Existing cassettes retain the original byte hash even when their stored
|
|
124
|
+
// text was redacted. Older/hand-written cassettes may only have the body.
|
|
125
|
+
const body = request.bodyHash ?? `sha256:${createHash("sha256").update(bodyBytes(request)).digest("hex")}`;
|
|
126
|
+
return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query, body });
|
|
127
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Isolate module mocks from other tests. Drive the actual record handler,
|
|
7
|
+
// replacing only DNS/HTTPS transport; no credentials or external egress.
|
|
8
|
+
test("recorded headers, redaction, and exact body bytes survive record → replay", async () => {
|
|
9
|
+
const moduleUrl = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
|
|
10
|
+
const script = `
|
|
11
|
+
import { strict as assert } from "node:assert";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import { mock, test } from "bun:test";
|
|
14
|
+
import * as dnsPacket from ${JSON.stringify(import.meta.resolve("dns-packet"))};
|
|
15
|
+
|
|
16
|
+
test("record round trip", async () => {
|
|
17
|
+
mock.module("node:dgram", () => ({ createSocket() {
|
|
18
|
+
const socket = new EventEmitter();
|
|
19
|
+
socket.close = () => {};
|
|
20
|
+
socket.send = (_query, _port, _host, done) => {
|
|
21
|
+
done();
|
|
22
|
+
queueMicrotask(() => socket.emit("message", dnsPacket.encode({
|
|
23
|
+
type: "response", id: 1,
|
|
24
|
+
answers: [{ type: "A", name: "api.example.test", ttl: 60, data: "127.0.0.1" }],
|
|
25
|
+
})));
|
|
26
|
+
};
|
|
27
|
+
return socket;
|
|
28
|
+
} }));
|
|
29
|
+
|
|
30
|
+
const upstreamHeaders = [];
|
|
31
|
+
mock.module("node:https", () => ({ request(options, callback) {
|
|
32
|
+
upstreamHeaders.push(options.headers);
|
|
33
|
+
const req = new EventEmitter();
|
|
34
|
+
req.write = () => {};
|
|
35
|
+
req.end = () => queueMicrotask(() => {
|
|
36
|
+
const res = new EventEmitter();
|
|
37
|
+
res.statusCode = 200;
|
|
38
|
+
res.headers = { "content-type": "text/plain" };
|
|
39
|
+
callback(res);
|
|
40
|
+
res.emit("data", Buffer.from("response"));
|
|
41
|
+
res.emit("end");
|
|
42
|
+
});
|
|
43
|
+
return req;
|
|
44
|
+
} }));
|
|
45
|
+
|
|
46
|
+
const { replayFake } = await import(${moduleUrl("./replayFake.ts")});
|
|
47
|
+
const { setRecordSecrets } = await import(${moduleUrl("../record-secrets.ts")});
|
|
48
|
+
setRecordSecrets({ API_KEY: "recording-secret" });
|
|
49
|
+
const options = {
|
|
50
|
+
name: "test", host: "api.example.test",
|
|
51
|
+
inject: [{ headers: { authorization: "Bearer {{secret:API_KEY}}" } }],
|
|
52
|
+
redactPatterns: ["private-field"],
|
|
53
|
+
};
|
|
54
|
+
const record = replayFake({ ...options, mode: "record" });
|
|
55
|
+
const state = record.state();
|
|
56
|
+
const helpers = record.helpers({ name: "test", state, ctx: {} });
|
|
57
|
+
const bodies = ["\\ufeffhello", new Uint8Array([0, 128, 255]), "recording-secret private-field"];
|
|
58
|
+
for (const [i, body] of bodies.entries()) {
|
|
59
|
+
const res = await record.handler(new Request("https://api.example.test/messages", {
|
|
60
|
+
method: "POST", body,
|
|
61
|
+
headers: {
|
|
62
|
+
"X-Task": "task-" + i,
|
|
63
|
+
authorization: "Bearer dummy", cookie: "dummy-cookie",
|
|
64
|
+
"api-key": "dummy-api-key", "x-goog-api-key": "dummy-google-key",
|
|
65
|
+
"x-extra": "recording-secret private-field",
|
|
66
|
+
},
|
|
67
|
+
}), state, {});
|
|
68
|
+
assert.equal(res.status, 200, await res.text());
|
|
69
|
+
}
|
|
70
|
+
assert.equal(helpers.count(), 3);
|
|
71
|
+
assert.equal(upstreamHeaders[0].authorization, "Bearer recording-secret");
|
|
72
|
+
const cassette = JSON.parse(JSON.stringify(helpers.dump()));
|
|
73
|
+
assert.equal(cassette.interactions[0].request.body, "\\ufeffhello");
|
|
74
|
+
assert.equal(cassette.interactions[1].request.bodyEncoding, "base64");
|
|
75
|
+
assert.equal(cassette.interactions[2].request.body, "<REDACTED:API_KEY> <REDACTED>");
|
|
76
|
+
for (const [i, it] of cassette.interactions.entries()) {
|
|
77
|
+
assert.equal(it.request.headers["x-task"], "task-" + i);
|
|
78
|
+
assert.equal(it.request.headers["x-extra"], "<REDACTED:API_KEY> <REDACTED>");
|
|
79
|
+
for (const name of ["authorization", "cookie", "api-key", "x-goog-api-key"]) {
|
|
80
|
+
assert.equal(it.request.headers[name], undefined);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
assert.equal(JSON.stringify(cassette).includes("recording-secret"), false);
|
|
84
|
+
|
|
85
|
+
for (const key of [undefined, r => r.headers["x-task"]]) {
|
|
86
|
+
const replay = replayFake({ ...options, mode: "replay", key });
|
|
87
|
+
const replayState = replay.state();
|
|
88
|
+
replay.helpers({ name: "test", state: replayState, ctx: {} }).replay(cassette);
|
|
89
|
+
for (const i of [2, 0, 1]) {
|
|
90
|
+
const res = await replay.handler(new Request("https://api.example.test/messages", {
|
|
91
|
+
method: "POST", body: bodies[i], headers: { "X-Task": "task-" + i },
|
|
92
|
+
}), replayState, {});
|
|
93
|
+
assert.equal(res.status, 200);
|
|
94
|
+
assert.equal(await res.text(), "response");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
assert.equal(upstreamHeaders.length, 3); // Replay never forwards.
|
|
98
|
+
});
|
|
99
|
+
`;
|
|
100
|
+
const dir = await mkdtemp(join(tmpdir(), "spectest-replay-record-"));
|
|
101
|
+
try {
|
|
102
|
+
const file = join(dir, "record.test.ts");
|
|
103
|
+
await writeFile(file, script);
|
|
104
|
+
const child = Bun.spawn([process.execPath, "test", file], {
|
|
105
|
+
stdout: "pipe", stderr: "pipe",
|
|
106
|
+
});
|
|
107
|
+
const [exit, stdout, stderr] = await Promise.all([
|
|
108
|
+
child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
|
|
109
|
+
]);
|
|
110
|
+
expect(exit, stdout + stderr).toBe(0);
|
|
111
|
+
} finally {
|
|
112
|
+
await rm(dir, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}, 10_000);
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import type { FakeContext } from "../index.js";
|
|
4
|
+
import { replayFake, type Cassette, type CassetteInteraction, type ReplayFakeOptions, type ReplayRequest, type ReplayKey, type MatchKey } from "./index.js";
|
|
5
|
+
|
|
6
|
+
const context = {} as FakeContext;
|
|
7
|
+
const host = "api.example.test";
|
|
8
|
+
const sha = (body: string | Uint8Array) => `sha256:${createHash("sha256").update(body).digest("hex")}`;
|
|
9
|
+
|
|
10
|
+
function interaction(body: string | Uint8Array, answer: string, path = "/messages"): CassetteInteraction {
|
|
11
|
+
return {
|
|
12
|
+
request: {
|
|
13
|
+
method: "POST", path, query: {}, headers: { "content-type": "application/json" },
|
|
14
|
+
body: typeof body === "string" ? body : Buffer.from(body).toString("base64"),
|
|
15
|
+
bodyEncoding: typeof body === "string" ? "utf8" : "base64",
|
|
16
|
+
bodyHash: sha(body),
|
|
17
|
+
},
|
|
18
|
+
response: { status: 200, headers: {}, body: answer, bodyEncoding: "utf8" },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cassette(interactions: CassetteInteraction[]): Cassette {
|
|
23
|
+
return { version: 1, fake: "test", host, interactions };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function harness(options: Partial<ReplayFakeOptions> = {}) {
|
|
27
|
+
const fake = replayFake({ name: "test", host, mode: "replay", ...options });
|
|
28
|
+
const state = await fake.state!();
|
|
29
|
+
const helpers = await fake.helpers!({ name: "test", state, ctx: context });
|
|
30
|
+
return {
|
|
31
|
+
state, helpers,
|
|
32
|
+
async send(body: string | Uint8Array = "", path = "/messages", init: RequestInit = {}) {
|
|
33
|
+
return await fake.handler(new Request(`https://${host}${path}`, {
|
|
34
|
+
method: "POST", body: body as BodyInit, ...init,
|
|
35
|
+
}), state, context);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("replay request identity", () => {
|
|
41
|
+
test("default matches 654 concurrent requests by body, independently of arrival order", async () => {
|
|
42
|
+
const h = await harness();
|
|
43
|
+
const calls = Array.from({ length: 654 }, (_, id) => interaction(JSON.stringify({ id }), `answer-${id}`));
|
|
44
|
+
h.helpers.replay(cassette(calls));
|
|
45
|
+
// A permutation visits every identity once in a different order.
|
|
46
|
+
const results = await Promise.all(calls.map(async (_, i) => {
|
|
47
|
+
const id = (i * 37) % calls.length;
|
|
48
|
+
const response = await h.send(JSON.stringify({ id }));
|
|
49
|
+
expect(response.status).toBe(200);
|
|
50
|
+
expect(await response.text()).toBe(`answer-${id}`);
|
|
51
|
+
}));
|
|
52
|
+
expect(results).toHaveLength(654);
|
|
53
|
+
expect(h.state.cursor.size).toBe(654);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("default uses method/path/normalized query/exact bytes, including old cassettes without hashes", async () => {
|
|
57
|
+
const h = await harness();
|
|
58
|
+
const it = interaction('{"a":1}', "ok");
|
|
59
|
+
delete it.request.bodyHash;
|
|
60
|
+
delete it.request.bodyEncoding;
|
|
61
|
+
it.request.query = { b: ["1", "2"], a: ["x"] };
|
|
62
|
+
h.helpers.replay(cassette([it]));
|
|
63
|
+
for (const [body, path, init] of [
|
|
64
|
+
['{ "a":1}', "/messages?a=x&b=2&b=1", {}],
|
|
65
|
+
['{"a":1}', "/other?a=x&b=2&b=1", {}],
|
|
66
|
+
['{"a":1}', "/messages?a=x&b=2&b=1", { method: "PUT" }],
|
|
67
|
+
['{"a":1}', "/messages?a=y&b=2&b=1", {}],
|
|
68
|
+
] as const) expect((await h.send(body, path, init)).status).toBe(599);
|
|
69
|
+
expect(await (await h.send('{"a":1}', "/messages?b=2&a=x&b=1")).text()).toBe("ok");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the existing committed HTTP cassette still replays with the new default", async () => {
|
|
73
|
+
const h = await harness();
|
|
74
|
+
const existing = await Bun.file(new URL("../../../examples/replay-fake/spectest/tests/recordings/httpbin.json", import.meta.url)).json() as Cassette;
|
|
75
|
+
h.helpers.replay(existing);
|
|
76
|
+
const response = await h.send("", "/get?hello=world", { method: "GET", body: null });
|
|
77
|
+
expect(response.status).toBe(200);
|
|
78
|
+
expect((await response.json()).args).toEqual({ hello: ["world"] });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("SigV4 query credentials stay outside default and custom identity", async () => {
|
|
82
|
+
for (const key of [undefined, (r: ReplayRequest) => ({ path: r.path, query: r.query })]) {
|
|
83
|
+
const h = await harness({ key, sign: { type: "awsSigv4", accessKeyId: "ACCESS", secretAccessKey: "SECRET" } });
|
|
84
|
+
h.helpers.replay(cassette([interaction("body", "ok")]));
|
|
85
|
+
expect(await (await h.send("body", "/messages?X-Amz-Signature=changing&X-Amz-Date=20261001")).text()).toBe("ok");
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("Dentio keys separate model/tasks while ignoring a changing date", async () => {
|
|
90
|
+
let evaluations = 0;
|
|
91
|
+
const h = await harness({ key(request) {
|
|
92
|
+
evaluations++;
|
|
93
|
+
const body = request.json<{ system: { text: string }[] }>();
|
|
94
|
+
return { model: request.path, task: body.system[0].text.slice(0, 200) };
|
|
95
|
+
} });
|
|
96
|
+
const calls = Array.from({ length: 8 }, (_, i) => ({
|
|
97
|
+
task: `task-${i}`.padEnd(200, "."),
|
|
98
|
+
path: `/model/${i < 4 ? "haiku" : "sonnet"}/converse`,
|
|
99
|
+
}));
|
|
100
|
+
const body = (task: string, date: string) => JSON.stringify({ system: [{ text: `${task} Today: ${date}` }] });
|
|
101
|
+
h.helpers.replay(cassette(calls.map((call, i) => interaction(body(call.task, "2026-10-01"), i === 4 ? "Karies 16o" : `task-${i}`, call.path))));
|
|
102
|
+
expect(evaluations).toBe(8);
|
|
103
|
+
for (const i of [7, 2, 4, 0, 6, 1, 5, 3]) {
|
|
104
|
+
expect(await (await h.send(body(calls[i].task, "2026-10-02"), calls[i].path)).text()).toBe(i === 4 ? "Karies 16o" : `task-${i}`);
|
|
105
|
+
}
|
|
106
|
+
expect(evaluations).toBe(16); // No rescanning/recomputing cassette keys.
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("custom key is the entire identity, with no implicit HTTP dimensions", async () => {
|
|
110
|
+
const h = await harness({ key: request => request.json<{ task: string }>().task });
|
|
111
|
+
h.helpers.replay(cassette([interaction('{"task":"same","date":"yesterday"}', "ok", "/old")]));
|
|
112
|
+
expect(await (await h.send('{"task":"same","date":"today"}', "/new?nonce=123", { method: "PUT" })).text()).toBe("ok");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("JSON objects compare structurally but arrays preserve order", async () => {
|
|
116
|
+
const h = await harness({ key: request => request.json() });
|
|
117
|
+
h.helpers.replay(cassette([interaction('{"b":2,"a":{"x":[1,2]}}', "ok")]));
|
|
118
|
+
expect((await h.send('{"a":{"x":[2,1]},"b":2}')).status).toBe(599);
|
|
119
|
+
expect(await (await h.send('{ "a": {"x":[1,2]}, "b": 2 }')).text()).toBe("ok");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("binary keys are lossless and distinct from JSON lookalikes", async () => {
|
|
123
|
+
const h = await harness({ key: request => ({ body: request.bytes() }) });
|
|
124
|
+
const bytes = new Uint8Array([0, 255, 128]);
|
|
125
|
+
h.helpers.replay(cassette([interaction(bytes, "binary")]));
|
|
126
|
+
expect((await h.send("[0,255,128]")).status).toBe(599);
|
|
127
|
+
expect(await (await h.send(bytes)).text()).toBe("binary");
|
|
128
|
+
|
|
129
|
+
const mixed = await harness({ key: r => r.path === "/binary" ? new Uint8Array([1, 2]) : r.json() });
|
|
130
|
+
mixed.helpers.replay(cassette([interaction("", "bytes", "/binary"), interaction("[1,2]", "array"), interaction('{"0":1,"1":2}', "object")]));
|
|
131
|
+
expect(await (await mixed.send("[1,2]")).text()).toBe("array");
|
|
132
|
+
expect(await (await mixed.send('{"0":1,"1":2}')).text()).toBe("object");
|
|
133
|
+
expect(await (await mixed.send("", "/binary")).text()).toBe("bytes");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("empty and BOM-prefixed bodies preserve their bytes", async () => {
|
|
137
|
+
for (const key of [undefined, (r: ReplayRequest) => r.bytes()]) {
|
|
138
|
+
const h = await harness({ key });
|
|
139
|
+
h.helpers.replay(cassette([interaction("", "empty"), interaction("\ufeffhello", "bom")]));
|
|
140
|
+
expect(await (await h.send("")).text()).toBe("empty");
|
|
141
|
+
expect((await h.send("hello")).status).toBe(599);
|
|
142
|
+
expect(await (await h.send("\ufeffhello")).text()).toBe("bom");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("same-key queues consume once, fail on exhaustion, and reset on reload", async () => {
|
|
147
|
+
const h = await harness();
|
|
148
|
+
const c = cassette([interaction("same", "first"), interaction("other", "other"), interaction("same", "second")]);
|
|
149
|
+
h.helpers.replay(c);
|
|
150
|
+
expect(await Promise.all([h.send("same"), h.send("same")].map(async p => (await p).text()))).toEqual(["first", "second"]);
|
|
151
|
+
const exhausted = await h.send("same");
|
|
152
|
+
expect(exhausted.status).toBe(599);
|
|
153
|
+
expect(await exhausted.text()).toContain("exhausted key");
|
|
154
|
+
expect(await (await h.send("missing")).text()).toContain("unknown key");
|
|
155
|
+
expect(await (await h.send("other")).text()).toBe("other");
|
|
156
|
+
h.helpers.replay(c);
|
|
157
|
+
expect(await (await h.send("same")).text()).toBe("first");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("cassette keys and cursors are isolated between fake states", async () => {
|
|
161
|
+
const a = await harness(), b = await harness();
|
|
162
|
+
const c = cassette([interaction("same", "ok")]);
|
|
163
|
+
a.helpers.replay(c); b.helpers.replay(c);
|
|
164
|
+
expect(await (await a.send("same")).text()).toBe("ok");
|
|
165
|
+
expect((await a.send("same")).status).toBe(599);
|
|
166
|
+
expect(await (await b.send("same")).text()).toBe("ok");
|
|
167
|
+
// Inline input cannot mutate a loaded/indexed cassette.
|
|
168
|
+
c.interactions[0].response.body = "mutated";
|
|
169
|
+
expect(a.helpers.dump().interactions[0].response.body).toBe("ok");
|
|
170
|
+
a.helpers.dump().interactions[0].response.body = "mutated dump";
|
|
171
|
+
expect(a.helpers.dump().interactions[0].response.body).toBe("ok");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("request accessors are synchronous, reusable, and omit internal hashes/credentials", async () => {
|
|
175
|
+
const h = await harness({ key(r) {
|
|
176
|
+
expect("bodyHash" in r).toBe(false);
|
|
177
|
+
expect(r.headers.authorization).toBeUndefined();
|
|
178
|
+
expect(r.headers.cookie).toBeUndefined();
|
|
179
|
+
expect(Object.isFrozen(r)).toBe(true);
|
|
180
|
+
expect(Object.isFrozen(r.query)).toBe(true);
|
|
181
|
+
const bytes = r.bytes(); bytes.fill(0);
|
|
182
|
+
expect(r.text()).toBe('{"a":1}');
|
|
183
|
+
expect(r.json<{ a: number }>()).toEqual({ a: 1 });
|
|
184
|
+
return r.headers["x-task"]!;
|
|
185
|
+
} });
|
|
186
|
+
const it = interaction('{"a":1}', "ok");
|
|
187
|
+
it.request.headers["x-task"] = "journal";
|
|
188
|
+
h.helpers.replay(cassette([it]));
|
|
189
|
+
expect(await (await h.send('{"a":1}', "/messages", { headers: { "X-Task": "journal", authorization: "secret", cookie: "secret" } })).text()).toBe("ok");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("bad incoming JSON fails without consuming a response; bad loads preserve live state", async () => {
|
|
193
|
+
const h = await harness({ key: r => r.json() });
|
|
194
|
+
h.helpers.replay(cassette([interaction("{}", "ok")]));
|
|
195
|
+
expect(() => h.helpers.replay(cassette([interaction("invalid", "bad")]))).toThrow("interaction #1");
|
|
196
|
+
const bad = await h.send("invalid");
|
|
197
|
+
expect(bad.status).toBe(599);
|
|
198
|
+
expect(await bad.text()).toContain("invalid replay key");
|
|
199
|
+
expect(await (await h.send("{}")).text()).toBe("ok");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test.each([
|
|
203
|
+
undefined, NaN, Infinity, BigInt(1), new Date(), new Map(), Promise.resolve("x"),
|
|
204
|
+
{ missing: undefined }, [undefined], Array(1), () => "x",
|
|
205
|
+
])("rejects unsupported keys instead of silently collapsing identities: %p", async value => {
|
|
206
|
+
const h = await harness({ key: () => value as MatchKey });
|
|
207
|
+
expect(() => h.helpers.replay(cassette([interaction("", "bad")]))).toThrow("invalid replay key");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("rejects cyclic keys but permits shared subobjects", async () => {
|
|
211
|
+
const value: Record<string, MatchKey> = {};
|
|
212
|
+
value.self = value;
|
|
213
|
+
const bad = await harness({ key: () => value });
|
|
214
|
+
expect(() => bad.helpers.replay(cassette([interaction("", "bad")]))).toThrow("cycles");
|
|
215
|
+
const shared = { a: 1 };
|
|
216
|
+
const good = await harness({ key: () => [shared, shared] });
|
|
217
|
+
good.helpers.replay(cassette([interaction("", "ok")]));
|
|
218
|
+
expect(await (await good.send()).text()).toBe("ok");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("rejects an async callback that throws without leaking an unhandled rejection", async () => {
|
|
222
|
+
const h = await harness({ key: (async () => { throw new Error("async failure"); }) as unknown as ReplayFakeOptions["key"] });
|
|
223
|
+
expect(() => h.helpers.replay(cassette([interaction("", "bad")]))).toThrow("synchronous");
|
|
224
|
+
await Promise.resolve();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("old match options fail with migration guidance", () => {
|
|
228
|
+
expect(() => replayFake({ name: "test", host, match: { body: "hash" } } as unknown as ReplayFakeOptions)).toThrow("has been replaced by `key(request)`");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Compile-time API checks, verified by the targeted TypeScript check as well
|
|
233
|
+
// as the SDK build. Never invoked: invalid callbacks must not run.
|
|
234
|
+
function publicTypes(request: ReplayRequest): void {
|
|
235
|
+
const json: ReplayKey = r => r.json();
|
|
236
|
+
const bytes: ReplayKey = r => ({ body: r.bytes(), query: r.query });
|
|
237
|
+
void [json, bytes];
|
|
238
|
+
// @ts-expect-error bodyHash is internal, not part of the callback view.
|
|
239
|
+
request.bodyHash;
|
|
240
|
+
// @ts-expect-error Request metadata is read-only.
|
|
241
|
+
request.method = "POST";
|
|
242
|
+
// @ts-expect-error Query values are read-only.
|
|
243
|
+
request.query.id.push("another");
|
|
244
|
+
// @ts-expect-error Callbacks must be synchronous.
|
|
245
|
+
const asyncKey: ReplayKey = async r => r.text();
|
|
246
|
+
// @ts-expect-error Undefined is not an identity.
|
|
247
|
+
const absentKey: ReplayKey = () => undefined;
|
|
248
|
+
void [asyncKey, absentKey];
|
|
249
|
+
replayFake({ name: "test", host,
|
|
250
|
+
// @ts-expect-error Per-dimension matching switches have been removed.
|
|
251
|
+
match: { body: "hash" },
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
void publicTypes;
|
|
@@ -65,6 +65,8 @@ import * as dnsPacket from "dns-packet";
|
|
|
65
65
|
import type { FakeContext, FakeDefinition } from "../index.js";
|
|
66
66
|
import { isRecording } from "../recorder.js";
|
|
67
67
|
import { getRecordSecret } from "../record-secrets.js";
|
|
68
|
+
import { replaySignature, type ReplayKey } from "./replay-key.js";
|
|
69
|
+
export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
|
|
68
70
|
import {
|
|
69
71
|
inferSigV4Scope,
|
|
70
72
|
signAwsV4Request,
|
|
@@ -75,21 +77,6 @@ import {
|
|
|
75
77
|
// Options + cassette format
|
|
76
78
|
// ────────────────────────────────────────────────────────────────────────
|
|
77
79
|
|
|
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
80
|
/** Match one request dimension. A bare string is an exact match; the
|
|
94
81
|
* object forms cover prefix and regex matching. */
|
|
95
82
|
export type StringMatch =
|
|
@@ -155,8 +142,12 @@ export interface ReplayFakeOptions {
|
|
|
155
142
|
* resolved via an external DNS server — see the module header). Your
|
|
156
143
|
* app points at this real host directly; no separate stand-in. */
|
|
157
144
|
host: string;
|
|
158
|
-
/**
|
|
159
|
-
|
|
145
|
+
/** Complete request identity. Default: method, path, normalized query,
|
|
146
|
+
* and exact body bytes. The same synchronous, deterministic callback runs
|
|
147
|
+
* on recorded and incoming requests. Return JSON values and/or Uint8Array
|
|
148
|
+
* bytes; objects compare independently of property order. No implicit
|
|
149
|
+
* matching dimensions or fallback apply when a key is supplied. */
|
|
150
|
+
key?: ReplayKey;
|
|
160
151
|
/** Per-fake credential brokering. Rules are evaluated in
|
|
161
152
|
* order; the first whose `match` matches wins (a rule without `match`
|
|
162
153
|
* matches everything and shadows later rules). Applied at the
|
|
@@ -182,7 +173,8 @@ export interface CassetteRequest {
|
|
|
182
173
|
path: string;
|
|
183
174
|
/** Normalized: keys + repeated values sorted, so matching is order-free. */
|
|
184
175
|
query: Record<string, string[]>;
|
|
185
|
-
/**
|
|
176
|
+
/** Captured non-sensitive headers — never `authorization`/`cookie`.
|
|
177
|
+
* Older cassettes may contain only content-type and selected headers. */
|
|
186
178
|
headers: Record<string, string>;
|
|
187
179
|
bodyHash?: string;
|
|
188
180
|
/** Request body (omitted when empty): decoded + redacted UTF-8 text, or
|
|
@@ -216,6 +208,8 @@ export interface Cassette {
|
|
|
216
208
|
/** Forked-per-test state for one replay fake. */
|
|
217
209
|
export interface CassetteState {
|
|
218
210
|
cassette: Cassette;
|
|
211
|
+
/** Complete signature → cassette interaction indexes, in cassette order. */
|
|
212
|
+
index: Map<string, number[]>;
|
|
219
213
|
/** Per-match-signature replay cursor (call-order sequencing). */
|
|
220
214
|
cursor: Map<string, number>;
|
|
221
215
|
/** Interactions captured this record session. */
|
|
@@ -263,7 +257,9 @@ const SENSITIVE_HEADERS = new Set([
|
|
|
263
257
|
"cookie",
|
|
264
258
|
"set-cookie",
|
|
265
259
|
"proxy-authorization",
|
|
260
|
+
"api-key",
|
|
266
261
|
"x-api-key",
|
|
262
|
+
"x-goog-api-key",
|
|
267
263
|
"x-amz-security-token",
|
|
268
264
|
]);
|
|
269
265
|
|
|
@@ -292,17 +288,6 @@ const STRIP_FORWARD_HEADERS = new Set([
|
|
|
292
288
|
const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
|
|
293
289
|
const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
|
|
294
290
|
|
|
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
291
|
/** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
|
|
307
292
|
* in the query for presigned URLs — they change every call and would poison
|
|
308
293
|
* query matching, so they're dropped from both the match key and the forward
|
|
@@ -341,27 +326,6 @@ function recordable(headers: Headers, want: string[]): Record<string, string> {
|
|
|
341
326
|
return out;
|
|
342
327
|
}
|
|
343
328
|
|
|
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
329
|
/** The project's `spectest/tests/` dir — cassettes live alongside the
|
|
366
330
|
* tests, and a `replay(path)` argument is a path relative to here. Keeping
|
|
367
331
|
* them under `tests/` means re-recording is picked up on the next run with
|
|
@@ -386,7 +350,7 @@ function coerceCassette(parsed: Cassette, name: string, host: string): Cassette
|
|
|
386
350
|
version: 1,
|
|
387
351
|
fake: parsed.fake ?? name,
|
|
388
352
|
host: parsed.host ?? host,
|
|
389
|
-
interactions: parsed.interactions,
|
|
353
|
+
interactions: structuredClone(parsed.interactions),
|
|
390
354
|
};
|
|
391
355
|
}
|
|
392
356
|
|
|
@@ -421,7 +385,7 @@ function readCassetteFile(name: string, file: string, host: string): Cassette {
|
|
|
421
385
|
/** Decode response bytes to a string, falling back to base64 for binary. */
|
|
422
386
|
function decodeBody(bytes: Uint8Array): { body: string; bodyEncoding: "utf8" | "base64" } {
|
|
423
387
|
try {
|
|
424
|
-
const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
|
|
388
|
+
const text = new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
425
389
|
return { body: text, bodyEncoding: "utf8" };
|
|
426
390
|
} catch {
|
|
427
391
|
return { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" };
|
|
@@ -671,7 +635,12 @@ export function replayFake(
|
|
|
671
635
|
throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
|
|
672
636
|
}
|
|
673
637
|
const host = opts.host.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
674
|
-
|
|
638
|
+
if ("match" in opts) {
|
|
639
|
+
throw new Error(`replayFake(${opts.name}): \`match\` has been replaced by \`key(request)\`, which defines the complete request identity`);
|
|
640
|
+
}
|
|
641
|
+
if (opts.key !== undefined && typeof opts.key !== "function") {
|
|
642
|
+
throw new Error(`replayFake(${opts.name}): \`key\` must be a synchronous function`);
|
|
643
|
+
}
|
|
675
644
|
const injectRules = opts.inject ?? [];
|
|
676
645
|
// Refs every header template references — the control plane resolves these
|
|
677
646
|
// server-side and pushes them for the environment lifetime (see the daemon's
|
|
@@ -685,19 +654,33 @@ export function replayFake(
|
|
|
685
654
|
};
|
|
686
655
|
|
|
687
656
|
// ── REPLAY ──────────────────────────────────────────────────────────
|
|
657
|
+
function signature(request: CassetteRequest, location: string): string {
|
|
658
|
+
try {
|
|
659
|
+
return replaySignature(request, opts.key);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
662
|
+
throw new Error(`replayFake(${opts.name}): invalid replay key for ${location}: ${message}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
688
666
|
function replay(reqLike: CassetteRequest, state: CassetteState): Response {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
667
|
+
let sig: string;
|
|
668
|
+
try {
|
|
669
|
+
sig = signature(reqLike, "incoming request");
|
|
670
|
+
} catch (error) {
|
|
671
|
+
return new Response((error as Error).message + "\n", {
|
|
672
|
+
status: 599, headers: { "content-type": "text/plain" },
|
|
673
|
+
});
|
|
693
674
|
}
|
|
675
|
+
const matches = state.index.get(sig) ?? [];
|
|
694
676
|
const idx = state.cursor.get(sig) ?? 0;
|
|
695
|
-
const
|
|
677
|
+
const interactionIndex = matches[idx];
|
|
678
|
+
const hit = interactionIndex === undefined ? undefined : state.cassette.interactions[interactionIndex];
|
|
696
679
|
if (!hit) {
|
|
697
680
|
const body =
|
|
698
681
|
`replayFake(${opts.name}): no cassette interaction for ` +
|
|
699
|
-
`${reqLike.method} ${reqLike.path} (matched ${matches.length}, wanted #${idx + 1}). ` +
|
|
700
|
-
`
|
|
682
|
+
`${reqLike.method} ${reqLike.path} (${matches.length === 0 ? "unknown key" : "exhausted key"}; matched ${matches.length}, wanted #${idx + 1}). ` +
|
|
683
|
+
`Check \`key\` or re-record the cassette.\n`;
|
|
701
684
|
return new Response(body, {
|
|
702
685
|
status: 599,
|
|
703
686
|
headers: { "content-type": "text/plain" },
|
|
@@ -883,7 +866,7 @@ export function replayFake(
|
|
|
883
866
|
method: reqLike.method,
|
|
884
867
|
path: reqLike.path,
|
|
885
868
|
query: redactedQuery,
|
|
886
|
-
headers: reqLike.headers,
|
|
869
|
+
headers: Object.fromEntries(Object.entries(reqLike.headers).map(([k, v]) => [k, redact(v)])),
|
|
887
870
|
...(reqLike.body !== undefined
|
|
888
871
|
? {
|
|
889
872
|
bodyHash: reqLike.bodyHash,
|
|
@@ -943,7 +926,7 @@ export function replayFake(
|
|
|
943
926
|
// Strip SigV4 `x-amz-*` query params from the match key when signing —
|
|
944
927
|
// presigned URLs carry a per-request signature there.
|
|
945
928
|
query: normalizeQuery(url.searchParams, opts.sign !== undefined),
|
|
946
|
-
headers: recordable(req.headers, [
|
|
929
|
+
headers: recordable(req.headers, [...req.headers.keys()]),
|
|
947
930
|
...(decodedBody
|
|
948
931
|
? {
|
|
949
932
|
bodyHash: sha256Bytes(bodyBytes),
|
|
@@ -967,6 +950,7 @@ export function replayFake(
|
|
|
967
950
|
// `replay(...)` to populate it.
|
|
968
951
|
state: (): CassetteState => ({
|
|
969
952
|
cassette: emptyCassette(opts.name, host),
|
|
953
|
+
index: new Map(),
|
|
970
954
|
cursor: new Map(),
|
|
971
955
|
recorded: [],
|
|
972
956
|
dirty: false,
|
|
@@ -988,22 +972,32 @@ export function replayFake(
|
|
|
988
972
|
`(e.g. "recordings/${opts.name}.json").`,
|
|
989
973
|
);
|
|
990
974
|
}
|
|
975
|
+
// Build/validate before replacing live state: a bad key/cassette
|
|
976
|
+
// must leave the previous cassette and consumption cursors intact.
|
|
977
|
+
const index = new Map<string, number[]>();
|
|
978
|
+
for (const [i, interaction] of loaded.interactions.entries()) {
|
|
979
|
+
const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
|
|
980
|
+
const queue = index.get(sig);
|
|
981
|
+
if (queue) queue.push(i);
|
|
982
|
+
else index.set(sig, [i]);
|
|
983
|
+
}
|
|
991
984
|
// Mutate the live state object (the handler reads the same
|
|
992
985
|
// reference); reset the cursor so replay starts from the top.
|
|
993
986
|
state.cassette = loaded;
|
|
987
|
+
state.index = index;
|
|
994
988
|
state.cursor = new Map();
|
|
995
989
|
return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
|
|
996
990
|
},
|
|
997
991
|
dump(): Cassette {
|
|
998
|
-
return {
|
|
992
|
+
return structuredClone({
|
|
999
993
|
version: 1,
|
|
1000
994
|
fake: opts.name,
|
|
1001
995
|
host,
|
|
1002
996
|
interactions: [...state.cassette.interactions, ...state.recorded],
|
|
1003
|
-
};
|
|
997
|
+
});
|
|
1004
998
|
},
|
|
1005
999
|
recorded(): CassetteInteraction[] {
|
|
1006
|
-
return state.recorded
|
|
1000
|
+
return structuredClone(state.recorded);
|
|
1007
1001
|
},
|
|
1008
1002
|
count(): number {
|
|
1009
1003
|
return state.recorded.length;
|