@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.
@@ -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 ReplayMatch, 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";
16
+ export { replayFake, type ReplayFakeOptions, type ReplayFakeContext, type ReplayHandler, 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
- import type { FakeDefinition } from "../index.js";
2
- /** Which parts of a request define a match against the cassette. */
3
- export interface ReplayMatch {
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
- }
1
+ import type { FakeContext, FakeDefinition } from "../index.js";
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 | {
@@ -66,7 +54,15 @@ export interface AwsSigV4Sign {
66
54
  sessionToken?: string;
67
55
  }
68
56
  export type SignConfig = AwsSigV4Sign;
69
- export interface ReplayFakeOptions {
57
+ /** A normal fake context with explicit delegation to recording/replay. */
58
+ export interface ReplayFakeContext extends FakeContext {
59
+ /** Record against the real upstream in record mode, or consume a matching
60
+ * cassette interaction in replay mode. A replay miss returns 599; there
61
+ * is no fallback to scripted logic. Inspect a body's clone before calling. */
62
+ replay(request: Request): Promise<Response>;
63
+ }
64
+ export type ReplayHandler<S> = (request: Request, state: S, ctx: ReplayFakeContext) => Response | Promise<Response>;
65
+ export interface ReplayFakeOptions<S = undefined, H extends Record<string, unknown> = {}> {
70
66
  /** Stable name — the `ctx.fakes` key. */
71
67
  name: string;
72
68
  /** The REAL host to fake, e.g. `"api.stripe.com"`. The fake answers for
@@ -75,8 +71,12 @@ export interface ReplayFakeOptions {
75
71
  * resolved via an external DNS server — see the module header). Your
76
72
  * app points at this real host directly; no separate stand-in. */
77
73
  host: string;
78
- /** What defines a request match. Default `{ method, path, query }`. */
79
- match?: ReplayMatch;
74
+ /** Complete request identity. Default: method, path, normalized query,
75
+ * and exact body bytes. The same synchronous, deterministic callback runs
76
+ * on recorded and incoming requests. Return JSON values and/or Uint8Array
77
+ * bytes; objects compare independently of property order. No implicit
78
+ * matching dimensions or fallback apply when a key is supplied. */
79
+ key?: ReplayKey;
80
80
  /** Per-fake credential brokering. Rules are evaluated in
81
81
  * order; the first whose `match` matches wins (a rule without `match`
82
82
  * matches everything and shadows later rules). Applied at the
@@ -95,13 +95,27 @@ export interface ReplayFakeOptions {
95
95
  /** `"auto"` (default) records under eval and replays under test;
96
96
  * `"record"` / `"replay"` force a mode. */
97
97
  mode?: "auto" | "record" | "replay";
98
+ /** Custom state, with the same lifecycle as a normal fake's state. Kept
99
+ * separate from cassette internals; helpers and handler see only S. */
100
+ state?: () => S | Promise<S>;
101
+ /** Custom helpers exposed alongside replay/dump/recorded/count. Those
102
+ * names are reserved. Loading a cassette does not change custom state. */
103
+ helpers?: (args: {
104
+ name: string;
105
+ state: S;
106
+ ctx: FakeContext;
107
+ }) => H | Promise<H>;
108
+ /** Decide whether to answer in code or call ctx.replay(request). Without
109
+ * a handler, every request delegates. Only delegated requests record. */
110
+ handler?: ReplayHandler<S>;
98
111
  }
99
112
  export interface CassetteRequest {
100
113
  method: string;
101
114
  path: string;
102
115
  /** Normalized: keys + repeated values sorted, so matching is order-free. */
103
116
  query: Record<string, string[]>;
104
- /** Matched/readable header subset only — never `authorization`/`cookie`. */
117
+ /** Captured non-sensitive headers — never `authorization`/`cookie`.
118
+ * Older cassettes may contain only content-type and selected headers. */
105
119
  headers: Record<string, string>;
106
120
  bodyHash?: string;
107
121
  /** Request body (omitted when empty): decoded + redacted UTF-8 text, or
@@ -131,6 +145,8 @@ export interface Cassette {
131
145
  /** Forked-per-test state for one replay fake. */
132
146
  export interface CassetteState {
133
147
  cassette: Cassette;
148
+ /** Complete signature → cassette interaction indexes, in cassette order. */
149
+ index: Map<string, number[]>;
134
150
  /** Per-match-signature replay cursor (call-order sequencing). */
135
151
  cursor: Map<string, number>;
136
152
  /** Interactions captured this record session. */
@@ -149,8 +165,9 @@ export interface ReplaySummary {
149
165
  interactions: number;
150
166
  }
151
167
  export interface ReplayHelpers extends Record<string, unknown> {
152
- /** Load a cassette into THIS fork's replay set and replay from it,
168
+ /** Load a cassette into THIS fork's replay set for delegated requests,
153
169
  * replacing whatever was loaded before and resetting the replay cursor.
170
+ * Does not change custom state or bypass a custom handler.
154
171
  * Cassettes are NOT auto-loaded — call this at the top of a test. Pass a
155
172
  * path relative to `spectest/tests/` (e.g. `"recordings/stripe.json"`; it
156
173
  * must stay under `spectest/tests/`) or a cassette object; a missing file
@@ -166,4 +183,6 @@ export interface ReplayHelpers extends Record<string, unknown> {
166
183
  /** Count of interactions captured this record session. */
167
184
  count(): number;
168
185
  }
169
- export declare function replayFake(opts: ReplayFakeOptions): FakeDefinition<CassetteState, ReplayHelpers>;
186
+ export declare function replayFake<S = undefined, H extends Record<string, unknown> = {}>(opts: ReplayFakeOptions<S, H>): FakeDefinition<CassetteState & {
187
+ custom: S;
188
+ }, ReplayHelpers & H>;
@@ -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
@@ -62,6 +64,7 @@ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
62
64
  import * as dnsPacket from "dns-packet";
63
65
  import { isRecording } from "../recorder.js";
64
66
  import { getRecordSecret } from "../record-secrets.js";
67
+ import { replaySignature } from "./replay-key.js";
65
68
  import { inferSigV4Scope, signAwsV4Request, } from "../aws-sigv4.js";
66
69
  // ────────────────────────────────────────────────────────────────────────
67
70
  // Helpers
@@ -71,7 +74,9 @@ const SENSITIVE_HEADERS = new Set([
71
74
  "cookie",
72
75
  "set-cookie",
73
76
  "proxy-authorization",
77
+ "api-key",
74
78
  "x-api-key",
79
+ "x-goog-api-key",
75
80
  "x-amz-security-token",
76
81
  ]);
77
82
  /** Hop-by-hop + body-framing headers never forwarded / never replayed
@@ -97,15 +102,6 @@ const STRIP_FORWARD_HEADERS = new Set([
97
102
  * resolver (which points the faked host back at the daemon). */
98
103
  const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
99
104
  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
105
  /** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
110
106
  * in the query for presigned URLs — they change every call and would poison
111
107
  * query matching, so they're dropped from both the match key and the forward
@@ -144,31 +140,6 @@ function recordable(headers, want) {
144
140
  }
145
141
  return out;
146
142
  }
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
143
  /** The project's `spectest/tests/` dir — cassettes live alongside the
173
144
  * tests, and a `replay(path)` argument is a path relative to here. Keeping
174
145
  * them under `tests/` means re-recording is picked up on the next run with
@@ -190,7 +161,7 @@ function coerceCassette(parsed, name, host) {
190
161
  version: 1,
191
162
  fake: parsed.fake ?? name,
192
163
  host: parsed.host ?? host,
193
- interactions: parsed.interactions,
164
+ interactions: structuredClone(parsed.interactions),
194
165
  };
195
166
  }
196
167
  /** Read a cassette by its path relative to `spectest/tests/` (e.g.
@@ -218,7 +189,7 @@ function readCassetteFile(name, file, host) {
218
189
  /** Decode response bytes to a string, falling back to base64 for binary. */
219
190
  function decodeBody(bytes) {
220
191
  try {
221
- const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
192
+ const text = new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(bytes);
222
193
  return { body: text, bodyEncoding: "utf8" };
223
194
  }
224
195
  catch {
@@ -434,7 +405,12 @@ export function replayFake(opts) {
434
405
  throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
435
406
  }
436
407
  const host = opts.host.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
437
- const match = defaultMatch(opts.match);
408
+ if ("match" in opts) {
409
+ throw new Error(`replayFake(${opts.name}): \`match\` has been replaced by \`key(request)\`, which defines the complete request identity`);
410
+ }
411
+ if (opts.key !== undefined && typeof opts.key !== "function") {
412
+ throw new Error(`replayFake(${opts.name}): \`key\` must be a synchronous function`);
413
+ }
438
414
  const injectRules = opts.inject ?? [];
439
415
  // Refs every header template references — the control plane resolves these
440
416
  // server-side and pushes them for the environment lifetime (see the daemon's
@@ -448,19 +424,33 @@ export function replayFake(opts) {
448
424
  return isRecording() ? "replay" : "record";
449
425
  };
450
426
  // ── REPLAY ──────────────────────────────────────────────────────────
427
+ function signature(request, location) {
428
+ try {
429
+ return replaySignature(request, opts.key);
430
+ }
431
+ catch (error) {
432
+ const message = error instanceof Error ? error.message : String(error);
433
+ throw new Error(`replayFake(${opts.name}): invalid replay key for ${location}: ${message}`);
434
+ }
435
+ }
451
436
  function replay(reqLike, state) {
452
- const sig = signature(reqLike, match);
453
- const matches = [];
454
- for (const it of state.cassette.interactions) {
455
- if (signature(it.request, match) === sig)
456
- matches.push(it);
437
+ let sig;
438
+ try {
439
+ sig = signature(reqLike, "incoming request");
457
440
  }
441
+ catch (error) {
442
+ return new Response(error.message + "\n", {
443
+ status: 599, headers: { "content-type": "text/plain" },
444
+ });
445
+ }
446
+ const matches = state.index.get(sig) ?? [];
458
447
  const idx = state.cursor.get(sig) ?? 0;
459
- const hit = matches[idx];
448
+ const interactionIndex = matches[idx];
449
+ const hit = interactionIndex === undefined ? undefined : state.cassette.interactions[interactionIndex];
460
450
  if (!hit) {
461
451
  const body = `replayFake(${opts.name}): no cassette interaction for ` +
462
- `${reqLike.method} ${reqLike.path} (matched ${matches.length}, wanted #${idx + 1}). ` +
463
- `Re-record the cassette, or relax \`match\`.\n`;
452
+ `${reqLike.method} ${reqLike.path} (${matches.length === 0 ? "unknown key" : "exhausted key"}; matched ${matches.length}, wanted #${idx + 1}). ` +
453
+ `Check \`key\` or re-record the cassette.\n`;
464
454
  return new Response(body, {
465
455
  status: 599,
466
456
  headers: { "content-type": "text/plain" },
@@ -618,7 +608,7 @@ export function replayFake(opts) {
618
608
  method: reqLike.method,
619
609
  path: reqLike.path,
620
610
  query: redactedQuery,
621
- headers: reqLike.headers,
611
+ headers: Object.fromEntries(Object.entries(reqLike.headers).map(([k, v]) => [k, redact(v)])),
622
612
  ...(reqLike.body !== undefined
623
613
  ? {
624
614
  bodyHash: reqLike.bodyHash,
@@ -656,6 +646,9 @@ export function replayFake(opts) {
656
646
  }
657
647
  // ── handler ─────────────────────────────────────────────────────────
658
648
  const handler = async (req, state, _ctx) => {
649
+ if (req.bodyUsed) {
650
+ throw new Error(`replayFake(${opts.name}): cannot delegate a consumed request body; inspect request.clone() before calling ctx.replay(request)`);
651
+ }
659
652
  const url = new URL(req.url);
660
653
  const bodyBytes = req.method === "GET" || req.method === "HEAD"
661
654
  ? new Uint8Array(0)
@@ -667,7 +660,7 @@ export function replayFake(opts) {
667
660
  // Strip SigV4 `x-amz-*` query params from the match key when signing —
668
661
  // presigned URLs carry a per-request signature there.
669
662
  query: normalizeQuery(url.searchParams, opts.sign !== undefined),
670
- headers: recordable(req.headers, ["content-type", ...(match.headers ?? [])]),
663
+ headers: recordable(req.headers, [...req.headers.keys()]),
671
664
  ...(decodedBody
672
665
  ? {
673
666
  bodyHash: sha256Bytes(bodyBytes),
@@ -680,58 +673,89 @@ export function replayFake(opts) {
680
673
  return replay(reqLike, state);
681
674
  return record(req, url, reqLike, bodyBytes, state);
682
675
  };
676
+ const replayHelpers = (state) => ({
677
+ replay(file) {
678
+ let loaded;
679
+ let label;
680
+ if (file && typeof file === "object") {
681
+ loaded = coerceCassette(file, opts.name, host);
682
+ label = file.fake || "(inline)";
683
+ }
684
+ else if (typeof file === "string" && file.length > 0) {
685
+ label = file;
686
+ loaded = readCassetteFile(opts.name, file, host);
687
+ }
688
+ else {
689
+ throw new Error(`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
690
+ `(e.g. "recordings/${opts.name}.json").`);
691
+ }
692
+ // Build/validate before replacing live state: a bad key/cassette
693
+ // must leave the previous cassette and consumption cursors intact.
694
+ const index = new Map();
695
+ for (const [i, interaction] of loaded.interactions.entries()) {
696
+ const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
697
+ const queue = index.get(sig);
698
+ if (queue)
699
+ queue.push(i);
700
+ else
701
+ index.set(sig, [i]);
702
+ }
703
+ // Mutate the live state object (the handler reads the same
704
+ // reference); reset the cursor so replay starts from the top.
705
+ state.cassette = loaded;
706
+ state.index = index;
707
+ state.cursor = new Map();
708
+ return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
709
+ },
710
+ dump() {
711
+ return structuredClone({
712
+ version: 1,
713
+ fake: opts.name,
714
+ host,
715
+ interactions: [...state.cassette.interactions, ...state.recorded],
716
+ });
717
+ },
718
+ recorded() {
719
+ return structuredClone(state.recorded);
720
+ },
721
+ count() {
722
+ return state.recorded.length;
723
+ },
724
+ });
725
+ const initialState = (custom) => ({
726
+ cassette: emptyCassette(opts.name, host),
727
+ index: new Map(),
728
+ cursor: new Map(),
729
+ recorded: [],
730
+ dirty: false,
731
+ custom,
732
+ });
683
733
  const def = {
684
734
  name: opts.name,
685
- // Fake = real: we answer for the real host on :80 and :443.
686
735
  hostnames: [host],
687
736
  secretRefs,
688
- // Cassettes are NOT auto-loaded — a fork starts with an empty replay
689
- // set, so a test that loads nothing fails loud (599). Tests call
690
- // `replay(...)` to populate it.
691
- state: () => ({
692
- cassette: emptyCassette(opts.name, host),
693
- cursor: new Map(),
694
- recorded: [],
695
- dirty: false,
696
- }),
697
- handler,
698
- helpers: ({ state }) => ({
699
- replay(file) {
700
- let loaded;
701
- let label;
702
- if (file && typeof file === "object") {
703
- loaded = coerceCassette(file, opts.name, host);
704
- label = file.fake || "(inline)";
705
- }
706
- else if (typeof file === "string" && file.length > 0) {
707
- label = file;
708
- loaded = readCassetteFile(opts.name, file, host);
709
- }
710
- else {
711
- throw new Error(`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
712
- `(e.g. "recordings/${opts.name}.json").`);
737
+ state: () => opts.state
738
+ ? Promise.resolve(opts.state()).then(initialState)
739
+ : initialState(undefined),
740
+ handler: (request, state, ctx) => opts.handler
741
+ ? opts.handler(request, state.custom, {
742
+ ...ctx,
743
+ replay: (delegated) => handler(delegated, state, ctx),
744
+ })
745
+ : handler(request, state, ctx),
746
+ helpers: ({ name, state, ctx }) => {
747
+ const builtins = replayHelpers(state);
748
+ if (!opts.helpers)
749
+ return builtins;
750
+ return Promise.resolve(opts.helpers({ name, state: state.custom, ctx })).then((custom) => {
751
+ for (const key of Object.keys(builtins)) {
752
+ if (key in custom) {
753
+ throw new Error(`replayFake(${opts.name}): custom helper ${JSON.stringify(key)} is reserved for recording/replay; choose another name`);
754
+ }
713
755
  }
714
- // Mutate the live state object (the handler reads the same
715
- // reference); reset the cursor so replay starts from the top.
716
- state.cassette = loaded;
717
- state.cursor = new Map();
718
- return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
719
- },
720
- dump() {
721
- return {
722
- version: 1,
723
- fake: opts.name,
724
- host,
725
- interactions: [...state.cassette.interactions, ...state.recorded],
726
- };
727
- },
728
- recorded() {
729
- return state.recorded.slice();
730
- },
731
- count() {
732
- return state.recorded.length;
733
- },
734
- }),
756
+ return { ...custom, ...builtins };
757
+ });
758
+ },
735
759
  };
736
760
  return def;
737
761
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.99.0",
3
+ "version": "0.101.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -85,7 +85,12 @@ export { okta, type OktaOptions } from "./okta.js";
85
85
  export {
86
86
  replayFake,
87
87
  type ReplayFakeOptions,
88
- type ReplayMatch,
88
+ type ReplayFakeContext,
89
+ type ReplayHandler,
90
+ type ReplayKey,
91
+ type ReplayRequest,
92
+ type MatchKey,
93
+ type JsonValue,
89
94
  type InjectRule,
90
95
  type InjectMatch,
91
96
  type StringMatch,