@specific.dev/spectest 0.7.0 → 0.9.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/package.json +1 -1
- package/src/components/index.ts +4 -1
- package/src/components/replayFake.ts +474 -140
- package/src/daemon.ts +35 -8
- package/src/index.ts +39 -3
- package/src/recorder.ts +6 -0
package/package.json
CHANGED
package/src/components/index.ts
CHANGED
|
@@ -27,8 +27,11 @@ export {
|
|
|
27
27
|
replayFake,
|
|
28
28
|
type ReplayFakeOptions,
|
|
29
29
|
type ReplayMatch,
|
|
30
|
-
type
|
|
30
|
+
type InjectRule,
|
|
31
|
+
type InjectMatch,
|
|
32
|
+
type StringMatch,
|
|
31
33
|
type ReplayHelpers,
|
|
34
|
+
type ReplaySummary,
|
|
32
35
|
type Cassette,
|
|
33
36
|
type CassetteInteraction,
|
|
34
37
|
type CassetteRequest,
|
|
@@ -6,42 +6,58 @@
|
|
|
6
6
|
// and registers the hostname, dispatch routes by `Host` header to the
|
|
7
7
|
// generated `handler`, and `state` forks per test like any fake.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// You fake the REAL host directly — `host: "api.stripe.com"`, not a
|
|
10
|
+
// stand-in. The fake answers for that name on both `http://` (:80) and
|
|
11
|
+
// `https://` (:443, in-VM-CA leaf cert), and the SAME name is what gets
|
|
12
|
+
// forwarded to the real upstream in record mode. The two modes:
|
|
10
13
|
//
|
|
11
|
-
// - REPLAY (hermetic): the request is matched against
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
14
|
+
// - REPLAY (hermetic): the request is matched against the cassette the
|
|
15
|
+
// test EXPLICITLY loaded (`ctx.fakes.<name>.replay(file)`) and the
|
|
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.
|
|
18
|
+
// This is what runs under `spectest test`.
|
|
19
|
+
// - RECORD (MITM): the request is forwarded to the REAL host and the
|
|
15
20
|
// request/response pair is captured (decoded, redacted), and the real
|
|
16
21
|
// response is returned to the app so a manual session behaves like
|
|
17
22
|
// production. This runs under `spectest_eval` / a manual env.
|
|
18
23
|
//
|
|
24
|
+
// Because the fake's hostname IS the real host, the in-VM resolver points
|
|
25
|
+
// that name at the daemon — so a naive `fetch("https://api.stripe.com")`
|
|
26
|
+
// from the record-mode forwarder would loop straight back into us. The
|
|
27
|
+
// forwarder therefore resolves the real IP itself against an EXTERNAL DNS
|
|
28
|
+
// server (default 1.1.1.1, `SPECTEST_UPSTREAM_DNS`), then opens a real TLS
|
|
29
|
+
// connection to that IP with the correct SNI/`Host` (validating the real
|
|
30
|
+
// public cert). This is a transparent MITM: the app's leg is terminated
|
|
31
|
+
// with our in-VM-CA leaf; the upstream leg is a genuine public-cert TLS
|
|
32
|
+
// handshake to the real host.
|
|
33
|
+
//
|
|
19
34
|
// Mode is chosen by `isRecording()`: it is true only inside an active
|
|
20
35
|
// recorder (a `spectest test` case), false in eval/manual. So `auto`
|
|
21
36
|
// (the default) replays under test and records under eval — no new
|
|
22
37
|
// control-plane mode flag. `mode: "record" | "replay"` overrides it.
|
|
23
38
|
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
39
|
+
// Credential brokering (per-fake): `inject` rules set
|
|
40
|
+
// headers on the egress forward — overwriting whatever the app sent — so
|
|
41
|
+
// app code never holds the credential. Header values embed `{{secret:REF}}`
|
|
42
|
+
// tokens; each REF is resolved server-side from the platform Secrets store
|
|
43
|
+
// and pushed eval-scoped from the control plane (via {@link getRecordSecret}).
|
|
44
|
+
// The real value lives only on the outbound wire to the real upstream and
|
|
45
|
+
// is redacted from the cassette (fail-closed) — it never enters project
|
|
46
|
+
// code, the tarball, the warm-cache hash, or any snapshot a hermetic run
|
|
47
|
+
// could fork.
|
|
48
|
+
|
|
49
|
+
import { createSocket } from "node:dgram";
|
|
30
50
|
import { createHash } from "node:crypto";
|
|
31
51
|
import { existsSync, readFileSync } from "node:fs";
|
|
52
|
+
import { request as httpsRequest } from "node:https";
|
|
32
53
|
import path from "node:path";
|
|
54
|
+
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
55
|
+
import * as dnsPacket from "dns-packet";
|
|
33
56
|
|
|
34
57
|
import type { FakeContext, FakeDefinition } from "../index.js";
|
|
35
58
|
import { isRecording } from "../recorder.js";
|
|
36
59
|
import { getRecordSecret } from "../record-secrets.js";
|
|
37
60
|
|
|
38
|
-
// Pristine `fetch`, captured at module load (project import time, before
|
|
39
|
-
// any per-test / per-eval fetch wrapper monkey-patches `globalThis.fetch`).
|
|
40
|
-
// The forwarder uses this so its outbound call isn't intercepted by the
|
|
41
|
-
// recorder — same reason the daemon's reverse-proxy keeps its own
|
|
42
|
-
// `NATIVE_FETCH`.
|
|
43
|
-
const NATIVE_FETCH: typeof fetch = globalThis.fetch.bind(globalThis);
|
|
44
|
-
|
|
45
61
|
// ────────────────────────────────────────────────────────────────────────
|
|
46
62
|
// Options + cassette format
|
|
47
63
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -61,39 +77,59 @@ export interface ReplayMatch {
|
|
|
61
77
|
headers?: string[];
|
|
62
78
|
}
|
|
63
79
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
/** Match one request dimension. A bare string is an exact match; the
|
|
81
|
+
* object forms cover prefix and regex matching. */
|
|
82
|
+
export type StringMatch =
|
|
83
|
+
| string
|
|
84
|
+
| { exact: string }
|
|
85
|
+
| { startsWith: string }
|
|
86
|
+
| { regex: string };
|
|
87
|
+
|
|
88
|
+
/** Request matchers for a credential-injection rule. A rule applies only
|
|
89
|
+
* when EVERY specified dimension matches; omit `match` to always apply. */
|
|
90
|
+
export interface InjectMatch {
|
|
91
|
+
/** Match the request path (no query). */
|
|
92
|
+
path?: StringMatch;
|
|
93
|
+
/** HTTP method(s) — any one matching satisfies it. */
|
|
94
|
+
method?: string | string[];
|
|
95
|
+
/** Query-param matchers (ANDed); each key's value(s) must match. */
|
|
96
|
+
query?: Record<string, StringMatch>;
|
|
97
|
+
/** Header matchers (ANDed; header names case-insensitive). */
|
|
98
|
+
headers?: Record<string, StringMatch>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One credential-brokering rule. When it matches a request, these headers
|
|
102
|
+
* are SET on the egress forward — overwriting whatever the app sent (so app
|
|
103
|
+
* code can't smuggle a different value past the broker). Header VALUES may
|
|
104
|
+
* embed `{{secret:REF}}` tokens; each `REF` is resolved server-side from the
|
|
105
|
+
* project's Secrets store and pushed eval-scoped — the real value never
|
|
106
|
+
* enters project code, the
|
|
107
|
+
* tarball, the warm-cache hash, or a cassette (it's redacted, fail-closed). */
|
|
108
|
+
export interface InjectRule {
|
|
109
|
+
match?: InjectMatch;
|
|
110
|
+
headers: Record<string, string>;
|
|
76
111
|
}
|
|
77
112
|
|
|
78
113
|
export interface ReplayFakeOptions {
|
|
79
114
|
/** Stable name — the `ctx.fakes` key. */
|
|
80
115
|
name: string;
|
|
81
|
-
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
* `"https://api.stripe.com"`). */
|
|
88
|
-
upstream: string;
|
|
89
|
-
/** TCP port the fake listens on. Default `80`. */
|
|
90
|
-
port?: number;
|
|
91
|
-
/** Cassette filename under `spectest/recordings/`. Default `<name>.json`. */
|
|
92
|
-
cassette?: string;
|
|
116
|
+
/** The REAL host to fake, e.g. `"api.stripe.com"`. The fake answers for
|
|
117
|
+
* this name on both `http://` (:80) and `https://` (:443), and the same
|
|
118
|
+
* name is forwarded to the real upstream in record mode (over HTTPS,
|
|
119
|
+
* resolved via an external DNS server — see the module header). Your
|
|
120
|
+
* app points at this real host directly; no separate stand-in. */
|
|
121
|
+
host: string;
|
|
93
122
|
/** What defines a request match. Default `{ method, path, query }`. */
|
|
94
123
|
match?: ReplayMatch;
|
|
95
|
-
/**
|
|
96
|
-
|
|
124
|
+
/** Per-fake credential brokering. Rules are evaluated in
|
|
125
|
+
* order; the first whose `match` matches wins (a rule without `match`
|
|
126
|
+
* matches everything and shadows later rules). Applied at the
|
|
127
|
+
* record-mode egress forward; resolved secret values are redacted from
|
|
128
|
+
* the cassette (fail-closed). */
|
|
129
|
+
inject?: InjectRule[];
|
|
130
|
+
/** Extra substrings/patterns to scrub from stored request/response
|
|
131
|
+
* bodies and queries (every injected secret value is always scrubbed). */
|
|
132
|
+
redactPatterns?: (string | RegExp)[];
|
|
97
133
|
/** `"auto"` (default) records under eval and replays under test;
|
|
98
134
|
* `"record"` / `"replay"` force a mode. */
|
|
99
135
|
mode?: "auto" | "record" | "replay";
|
|
@@ -127,7 +163,8 @@ export interface CassetteInteraction {
|
|
|
127
163
|
export interface Cassette {
|
|
128
164
|
version: 1;
|
|
129
165
|
fake: string;
|
|
130
|
-
|
|
166
|
+
/** The real host this cassette mirrors (e.g. `api.stripe.com`). */
|
|
167
|
+
host: string;
|
|
131
168
|
interactions: CassetteInteraction[];
|
|
132
169
|
}
|
|
133
170
|
|
|
@@ -141,8 +178,28 @@ export interface CassetteState {
|
|
|
141
178
|
dirty: boolean;
|
|
142
179
|
}
|
|
143
180
|
|
|
181
|
+
/** What `replay(...)` returns — a small summary that surfaces in the test
|
|
182
|
+
* timeline (recorded as a `fake` event) so the run shows exactly which
|
|
183
|
+
* cassette a test loaded and how many interactions it carried. */
|
|
184
|
+
export interface ReplaySummary {
|
|
185
|
+
/** Filename loaded (under `spectest/recordings/`) or `"(inline)"`. */
|
|
186
|
+
cassette: string;
|
|
187
|
+
/** The real host the cassette mirrors. */
|
|
188
|
+
host: string;
|
|
189
|
+
/** Number of interactions now available for replay. */
|
|
190
|
+
interactions: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
144
193
|
export interface ReplayHelpers extends Record<string, unknown> {
|
|
145
|
-
/**
|
|
194
|
+
/** Load a cassette into THIS fork's replay set and replay from it,
|
|
195
|
+
* replacing whatever was loaded before and resetting the replay cursor.
|
|
196
|
+
* Cassettes are NOT auto-loaded — call this at the top of a test. Pass a
|
|
197
|
+
* path relative to `spectest/tests/` (e.g. `"recordings/stripe.json"`; it
|
|
198
|
+
* must stay under `spectest/tests/`) or a cassette object; a missing file
|
|
199
|
+
* throws. The returned summary is recorded as a step, so the UI timeline
|
|
200
|
+
* shows what was loaded. */
|
|
201
|
+
replay(file: string | Cassette): ReplaySummary;
|
|
202
|
+
/** Full cassette JSON (loaded interactions + newly recorded, redacted)
|
|
146
203
|
* — the MVP delivery channel: `export default ctx.fakes.<name>.dump()`,
|
|
147
204
|
* then write the result to `spectest/recordings/<name>.json`. */
|
|
148
205
|
dump(): Cassette;
|
|
@@ -166,8 +223,8 @@ const SENSITIVE_HEADERS = new Set([
|
|
|
166
223
|
]);
|
|
167
224
|
|
|
168
225
|
/** Hop-by-hop + body-framing headers never forwarded / never replayed
|
|
169
|
-
* (
|
|
170
|
-
* describe the bytes). */
|
|
226
|
+
* (we decompress on the way through, so the encoding/length no longer
|
|
227
|
+
* describe the bytes the app receives). */
|
|
171
228
|
const STRIP_FORWARD_HEADERS = new Set([
|
|
172
229
|
"host",
|
|
173
230
|
"connection",
|
|
@@ -180,8 +237,16 @@ const STRIP_FORWARD_HEADERS = new Set([
|
|
|
180
237
|
"upgrade",
|
|
181
238
|
"content-length",
|
|
182
239
|
"content-encoding",
|
|
240
|
+
// We force `accept-encoding: identity` on the forward and decompress any
|
|
241
|
+
// residual encoding, so the app never sees a stale accept-encoding.
|
|
242
|
+
"accept-encoding",
|
|
183
243
|
]);
|
|
184
244
|
|
|
245
|
+
/** External resolver used to find the REAL upstream IP, bypassing the in-VM
|
|
246
|
+
* resolver (which points the faked host back at the daemon). */
|
|
247
|
+
const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
|
|
248
|
+
const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
|
|
249
|
+
|
|
185
250
|
function defaultMatch(m: ReplayMatch | undefined): Required<Omit<ReplayMatch, "body" | "headers">> &
|
|
186
251
|
Pick<ReplayMatch, "body" | "headers"> {
|
|
187
252
|
return {
|
|
@@ -237,41 +302,64 @@ function signature(req: CassetteRequest, match: ReturnType<typeof defaultMatch>)
|
|
|
237
302
|
return JSON.stringify(sig);
|
|
238
303
|
}
|
|
239
304
|
|
|
240
|
-
|
|
241
|
-
|
|
305
|
+
/** The project's `spectest/tests/` dir — cassettes live alongside the
|
|
306
|
+
* tests, and a `replay(path)` argument is a path relative to here. Keeping
|
|
307
|
+
* them under `tests/` means re-recording is picked up on the next run with
|
|
308
|
+
* no environment rebuild (that subtree is re-imported with the tests). */
|
|
309
|
+
function cassetteRoot(): string {
|
|
310
|
+
const spectestDir =
|
|
242
311
|
process.env.SPECTEST_PROJECT_DIR ??
|
|
243
312
|
path.join(process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app", "spectest");
|
|
244
|
-
return path.join(
|
|
313
|
+
return path.join(spectestDir, "tests");
|
|
245
314
|
}
|
|
246
315
|
|
|
247
|
-
function emptyCassette(name: string,
|
|
248
|
-
return { version: 1, fake: name,
|
|
316
|
+
function emptyCassette(name: string, host: string): Cassette {
|
|
317
|
+
return { version: 1, fake: name, host, interactions: [] };
|
|
249
318
|
}
|
|
250
319
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (!
|
|
320
|
+
/** Validate + normalize a parsed cassette (from a file or passed inline). */
|
|
321
|
+
function coerceCassette(parsed: Cassette, name: string, host: string): Cassette {
|
|
322
|
+
if (!parsed || !Array.isArray(parsed.interactions)) {
|
|
323
|
+
throw new Error(`replayFake(${name}): cassette has no \`interactions\` array`);
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
version: 1,
|
|
327
|
+
fake: parsed.fake ?? name,
|
|
328
|
+
host: parsed.host ?? host,
|
|
329
|
+
interactions: parsed.interactions,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Read a cassette by its path relative to `spectest/tests/` (e.g.
|
|
334
|
+
* `"recordings/stripe.json"`). The path must stay under `spectest/tests/`,
|
|
335
|
+
* and explicit loads must point at a real file — a missing one throws (fail
|
|
336
|
+
* loud) rather than silently replaying nothing. */
|
|
337
|
+
function readCassetteFile(name: string, file: string, host: string): Cassette {
|
|
338
|
+
const base = cassetteRoot();
|
|
339
|
+
const p = path.resolve(base, file);
|
|
340
|
+
if (p !== base && !p.startsWith(base + path.sep)) {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`replayFake(${name}): cassette path ${JSON.stringify(file)} must be under spectest/tests/`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (!existsSync(p)) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`replayFake(${name}): cassette not found at ${p} — record one first, or pass a cassette object.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
let parsed: Cassette;
|
|
254
351
|
try {
|
|
255
|
-
|
|
256
|
-
if (!Array.isArray(parsed.interactions)) {
|
|
257
|
-
throw new Error("cassette has no `interactions` array");
|
|
258
|
-
}
|
|
259
|
-
return {
|
|
260
|
-
version: 1,
|
|
261
|
-
fake: parsed.fake ?? name,
|
|
262
|
-
upstream: parsed.upstream ?? upstream,
|
|
263
|
-
interactions: parsed.interactions,
|
|
264
|
-
};
|
|
352
|
+
parsed = JSON.parse(readFileSync(p, "utf8")) as Cassette;
|
|
265
353
|
} catch (err) {
|
|
266
354
|
throw new Error(
|
|
267
355
|
`replayFake(${name}): failed to read cassette ${p}: ${(err as Error).message}`,
|
|
268
356
|
);
|
|
269
357
|
}
|
|
358
|
+
return coerceCassette(parsed, name, host);
|
|
270
359
|
}
|
|
271
360
|
|
|
272
361
|
/** Decode response bytes to a string, falling back to base64 for binary. */
|
|
273
|
-
function decodeBody(
|
|
274
|
-
const bytes = new Uint8Array(buf);
|
|
362
|
+
function decodeBody(bytes: Uint8Array): { body: string; bodyEncoding: "utf8" | "base64" } {
|
|
275
363
|
try {
|
|
276
364
|
const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
|
|
277
365
|
return { body: text, bodyEncoding: "utf8" };
|
|
@@ -280,15 +368,21 @@ function decodeBody(buf: ArrayBuffer): { body: string; bodyEncoding: "utf8" | "b
|
|
|
280
368
|
}
|
|
281
369
|
}
|
|
282
370
|
|
|
371
|
+
/** One resolved secret value and the ref it came from (for the redaction
|
|
372
|
+
* placeholder). */
|
|
373
|
+
interface ResolvedSecret {
|
|
374
|
+
ref: string;
|
|
375
|
+
value: string;
|
|
376
|
+
}
|
|
377
|
+
|
|
283
378
|
function makeRedactor(
|
|
284
|
-
|
|
379
|
+
secrets: ResolvedSecret[],
|
|
285
380
|
patterns: (string | RegExp)[] | undefined,
|
|
286
|
-
ref: string | undefined,
|
|
287
381
|
): (s: string) => string {
|
|
288
382
|
return (s: string): string => {
|
|
289
383
|
let out = s;
|
|
290
|
-
|
|
291
|
-
out = out.split(
|
|
384
|
+
for (const { ref, value } of secrets) {
|
|
385
|
+
if (value.length > 0) out = out.split(value).join(`<REDACTED:${ref}>`);
|
|
292
386
|
}
|
|
293
387
|
for (const p of patterns ?? []) {
|
|
294
388
|
if (typeof p === "string") {
|
|
@@ -302,6 +396,204 @@ function makeRedactor(
|
|
|
302
396
|
};
|
|
303
397
|
}
|
|
304
398
|
|
|
399
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
400
|
+
// Credential brokering: matchers + secret-token resolution
|
|
401
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
/** `{{secret:REF}}` template token embedded in an injected header value. */
|
|
404
|
+
const SECRET_TOKEN = /\{\{secret:([A-Za-z0-9_]+)\}\}/g;
|
|
405
|
+
|
|
406
|
+
/** Every secret ref referenced by any inject rule's header templates. */
|
|
407
|
+
function collectSecretRefs(rules: InjectRule[]): string[] {
|
|
408
|
+
const refs = new Set<string>();
|
|
409
|
+
for (const rule of rules) {
|
|
410
|
+
for (const value of Object.values(rule.headers)) {
|
|
411
|
+
for (const m of value.matchAll(SECRET_TOKEN)) refs.add(m[1]);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return [...refs];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function matchString(m: StringMatch, value: string): boolean {
|
|
418
|
+
if (typeof m === "string") return value === m;
|
|
419
|
+
if ("exact" in m) return value === m.exact;
|
|
420
|
+
if ("startsWith" in m) return value.startsWith(m.startsWith);
|
|
421
|
+
if ("regex" in m) return new RegExp(m.regex).test(value);
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
interface IncomingRequest {
|
|
426
|
+
method: string;
|
|
427
|
+
path: string;
|
|
428
|
+
query: URLSearchParams;
|
|
429
|
+
headers: Headers;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** True if every dimension the matcher specifies is satisfied (AND across
|
|
433
|
+
* dimensions). No `match` → always true. */
|
|
434
|
+
function ruleMatches(match: InjectMatch | undefined, req: IncomingRequest): boolean {
|
|
435
|
+
if (!match) return true;
|
|
436
|
+
if (match.path !== undefined && !matchString(match.path, req.path)) return false;
|
|
437
|
+
if (match.method !== undefined) {
|
|
438
|
+
const methods = (Array.isArray(match.method) ? match.method : [match.method]).map((s) =>
|
|
439
|
+
s.toUpperCase(),
|
|
440
|
+
);
|
|
441
|
+
if (!methods.includes(req.method.toUpperCase())) return false;
|
|
442
|
+
}
|
|
443
|
+
if (match.query) {
|
|
444
|
+
for (const [k, m] of Object.entries(match.query)) {
|
|
445
|
+
if (!req.query.getAll(k).some((v) => matchString(m, v))) return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (match.headers) {
|
|
449
|
+
for (const [k, m] of Object.entries(match.headers)) {
|
|
450
|
+
const v = req.headers.get(k.toLowerCase());
|
|
451
|
+
if (v === null || !matchString(m, v)) return false;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
interface BrokeredHeaders {
|
|
458
|
+
/** Lowercased header → resolved value, to SET on the forward. */
|
|
459
|
+
headers: Record<string, string>;
|
|
460
|
+
/** Resolved secrets (for redaction). */
|
|
461
|
+
secrets: ResolvedSecret[];
|
|
462
|
+
/** Refs referenced but not supplied by the control plane. */
|
|
463
|
+
missing: string[];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Resolve a rule's header templates, substituting every `{{secret:REF}}`
|
|
467
|
+
* with the eval-scoped value. Collects resolved secrets (for redaction)
|
|
468
|
+
* and any refs the control plane didn't supply (fail loud). */
|
|
469
|
+
function brokerHeaders(rule: InjectRule): BrokeredHeaders {
|
|
470
|
+
const headers: Record<string, string> = {};
|
|
471
|
+
const secrets: ResolvedSecret[] = [];
|
|
472
|
+
const missing: string[] = [];
|
|
473
|
+
const seen = new Set<string>();
|
|
474
|
+
for (const [name, template] of Object.entries(rule.headers)) {
|
|
475
|
+
headers[name.toLowerCase()] = template.replace(SECRET_TOKEN, (_full, ref: string) => {
|
|
476
|
+
const value = getRecordSecret(ref);
|
|
477
|
+
if (value === undefined) {
|
|
478
|
+
missing.push(ref);
|
|
479
|
+
return "";
|
|
480
|
+
}
|
|
481
|
+
if (!seen.has(ref)) {
|
|
482
|
+
seen.add(ref);
|
|
483
|
+
secrets.push({ ref, value });
|
|
484
|
+
}
|
|
485
|
+
return value;
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
return { headers, secrets, missing };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
492
|
+
// Record-mode egress: resolve the real host externally, then MITM-forward
|
|
493
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
/** Resolve a host's A records against an EXTERNAL DNS server, bypassing the
|
|
496
|
+
* in-VM resolver (which would answer with the daemon's own gateway for a
|
|
497
|
+
* faked host). Mirrors spectest-resolver's own upstream-forward transport
|
|
498
|
+
* (dns-packet over UDP) so it works identically in-VM. */
|
|
499
|
+
function resolveExternalA(host: string): Promise<string[]> {
|
|
500
|
+
const query = dnsPacket.encode({
|
|
501
|
+
type: "query",
|
|
502
|
+
id: 1,
|
|
503
|
+
flags: dnsPacket.RECURSION_DESIRED,
|
|
504
|
+
questions: [{ type: "A", name: host }],
|
|
505
|
+
});
|
|
506
|
+
return new Promise((resolve) => {
|
|
507
|
+
const sock = createSocket("udp4");
|
|
508
|
+
let done = false;
|
|
509
|
+
const finish = (ips: string[]): void => {
|
|
510
|
+
if (done) return;
|
|
511
|
+
done = true;
|
|
512
|
+
try {
|
|
513
|
+
sock.close();
|
|
514
|
+
} catch {
|
|
515
|
+
// ignore
|
|
516
|
+
}
|
|
517
|
+
resolve(ips);
|
|
518
|
+
};
|
|
519
|
+
sock.on("message", (msg) => {
|
|
520
|
+
try {
|
|
521
|
+
const res = dnsPacket.decode(msg);
|
|
522
|
+
const ips = (res.answers ?? [])
|
|
523
|
+
.filter((a) => a.type === "A")
|
|
524
|
+
.map((a) => a.data as string)
|
|
525
|
+
.filter((d) => typeof d === "string" && d.length > 0);
|
|
526
|
+
finish(ips);
|
|
527
|
+
} catch {
|
|
528
|
+
finish([]);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
sock.on("error", () => finish([]));
|
|
532
|
+
sock.send(query, EXTERNAL_DNS_PORT, EXTERNAL_DNS, (err) => {
|
|
533
|
+
if (err) finish([]);
|
|
534
|
+
});
|
|
535
|
+
setTimeout(() => finish([]), 3_000);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
interface UpstreamResponse {
|
|
540
|
+
status: number;
|
|
541
|
+
headers: Record<string, string>;
|
|
542
|
+
/** Decompressed body bytes. */
|
|
543
|
+
bytes: Uint8Array;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Open a real HTTPS connection to `ip` but with SNI + `Host` = `host`, so
|
|
547
|
+
* the public certificate validates against the real hostname (the in-VM CA
|
|
548
|
+
* is not involved on this leg). Decompresses the response so the stored and
|
|
549
|
+
* returned bytes are the plain payload. */
|
|
550
|
+
function forwardToRealUpstream(args: {
|
|
551
|
+
ip: string;
|
|
552
|
+
host: string;
|
|
553
|
+
method: string;
|
|
554
|
+
pathWithQuery: string;
|
|
555
|
+
headers: Record<string, string>;
|
|
556
|
+
body: string;
|
|
557
|
+
}): Promise<UpstreamResponse> {
|
|
558
|
+
return new Promise((resolve, reject) => {
|
|
559
|
+
const req = httpsRequest(
|
|
560
|
+
{
|
|
561
|
+
host: args.ip,
|
|
562
|
+
port: 443,
|
|
563
|
+
method: args.method,
|
|
564
|
+
path: args.pathWithQuery,
|
|
565
|
+
servername: args.host,
|
|
566
|
+
headers: { ...args.headers, host: args.host, "accept-encoding": "identity" },
|
|
567
|
+
},
|
|
568
|
+
(res) => {
|
|
569
|
+
const chunks: Buffer[] = [];
|
|
570
|
+
res.on("data", (c: Buffer) => chunks.push(c));
|
|
571
|
+
res.on("end", () => {
|
|
572
|
+
let bytes: Uint8Array = Buffer.concat(chunks);
|
|
573
|
+
const enc = String(res.headers["content-encoding"] ?? "").toLowerCase();
|
|
574
|
+
try {
|
|
575
|
+
if (enc.includes("br")) bytes = brotliDecompressSync(bytes);
|
|
576
|
+
else if (enc.includes("gzip")) bytes = gunzipSync(bytes);
|
|
577
|
+
else if (enc.includes("deflate")) bytes = inflateSync(bytes);
|
|
578
|
+
} catch {
|
|
579
|
+
// Leave the bytes as-is if decompression fails.
|
|
580
|
+
}
|
|
581
|
+
const headers: Record<string, string> = {};
|
|
582
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
583
|
+
if (typeof v === "string") headers[k.toLowerCase()] = v;
|
|
584
|
+
else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(", ");
|
|
585
|
+
}
|
|
586
|
+
resolve({ status: res.statusCode ?? 0, headers, bytes });
|
|
587
|
+
});
|
|
588
|
+
res.on("error", reject);
|
|
589
|
+
},
|
|
590
|
+
);
|
|
591
|
+
req.on("error", reject);
|
|
592
|
+
if (args.body.length > 0) req.write(args.body);
|
|
593
|
+
req.end();
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
305
597
|
// ────────────────────────────────────────────────────────────────────────
|
|
306
598
|
// replayFake
|
|
307
599
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -310,21 +602,16 @@ export function replayFake(
|
|
|
310
602
|
opts: ReplayFakeOptions,
|
|
311
603
|
): FakeDefinition<CassetteState, ReplayHelpers> {
|
|
312
604
|
if (!opts.name) throw new Error("replayFake: `name` is required");
|
|
313
|
-
if (!
|
|
314
|
-
throw new Error(`replayFake(${opts.name}):
|
|
315
|
-
}
|
|
316
|
-
if (!opts.upstream) {
|
|
317
|
-
throw new Error(`replayFake(${opts.name}): "upstream" is required`);
|
|
605
|
+
if (!opts.host || typeof opts.host !== "string") {
|
|
606
|
+
throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
|
|
318
607
|
}
|
|
319
|
-
const
|
|
320
|
-
const upstream = opts.upstream.replace(/\/+$/, "");
|
|
608
|
+
const host = opts.host.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
321
609
|
const match = defaultMatch(opts.match);
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}));
|
|
610
|
+
const injectRules = opts.inject ?? [];
|
|
611
|
+
// Refs every header template references — the control plane resolves these
|
|
612
|
+
// server-side and pushes them eval-scoped (see the daemon's
|
|
613
|
+
// /record-secret-refs endpoint, which reads `def.secretRefs`).
|
|
614
|
+
const secretRefs = collectSecretRefs(injectRules);
|
|
328
615
|
|
|
329
616
|
const resolveMode = (): "record" | "replay" => {
|
|
330
617
|
if (opts.mode === "record") return "record";
|
|
@@ -360,67 +647,86 @@ export function replayFake(
|
|
|
360
647
|
return new Response(payload, { status: hit.response.status, headers });
|
|
361
648
|
}
|
|
362
649
|
|
|
363
|
-
// ── RECORD
|
|
650
|
+
// ── RECORD (MITM) ───────────────────────────────────────────────────
|
|
364
651
|
async function record(
|
|
365
652
|
req: Request,
|
|
653
|
+
url: URL,
|
|
366
654
|
reqLike: CassetteRequest,
|
|
367
655
|
rawBody: string,
|
|
368
656
|
state: CassetteState,
|
|
369
657
|
): Promise<Response> {
|
|
370
|
-
|
|
371
|
-
|
|
658
|
+
// Credential brokering: first matching inject rule wins. Resolve its
|
|
659
|
+
// `{{secret:REF}}` tokens from the eval-scoped store; a
|
|
660
|
+
// referenced-but-unsupplied ref fails loud.
|
|
661
|
+
const rule = injectRules.find((r) =>
|
|
662
|
+
ruleMatches(r.match, {
|
|
663
|
+
method: reqLike.method,
|
|
664
|
+
path: reqLike.path,
|
|
665
|
+
query: url.searchParams,
|
|
666
|
+
headers: req.headers,
|
|
667
|
+
}),
|
|
668
|
+
);
|
|
669
|
+
const brokered = rule ? brokerHeaders(rule) : { headers: {}, secrets: [], missing: [] };
|
|
670
|
+
if (brokered.missing.length > 0) {
|
|
671
|
+
const refs = [...new Set(brokered.missing)];
|
|
372
672
|
return new Response(
|
|
373
|
-
`replayFake(${opts.name}): secret ${JSON.stringify(
|
|
374
|
-
`(
|
|
673
|
+
`replayFake(${opts.name}): secret(s) ${JSON.stringify(refs)} were not supplied ` +
|
|
674
|
+
`(configure them on the project's Secrets page, and record via spectest_eval).\n`,
|
|
375
675
|
{ status: 599, headers: { "content-type": "text/plain" } },
|
|
376
676
|
);
|
|
377
677
|
}
|
|
378
678
|
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
// this
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
`forwarding would loop back into the daemon. Use a distinct fake hostname ` +
|
|
389
|
-
`(e.g. "api.${opts.name}.test") pointed at upstream ${upstream}.`,
|
|
679
|
+
// Resolve the REAL host's IP via an external resolver so we don't loop
|
|
680
|
+
// back into the daemon (the in-VM resolver answers our own gateway for
|
|
681
|
+
// this faked name). See the module header.
|
|
682
|
+
const ips = await resolveExternalA(host);
|
|
683
|
+
if (ips.length === 0) {
|
|
684
|
+
return new Response(
|
|
685
|
+
`replayFake(${opts.name}): could not resolve real upstream ${JSON.stringify(host)} ` +
|
|
686
|
+
`via external DNS ${EXTERNAL_DNS} (is the VM online?).\n`,
|
|
687
|
+
{ status: 599, headers: { "content-type": "text/plain" } },
|
|
390
688
|
);
|
|
391
689
|
}
|
|
392
690
|
|
|
393
|
-
// Forward headers: drop hop-by-hop, then
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
691
|
+
// Forward headers: drop hop-by-hop/encoding, then SET the brokered
|
|
692
|
+
// headers — overwriting whatever the app sent (so the credential can't
|
|
693
|
+
// be smuggled or spoofed by app code). The real values only ever appear
|
|
694
|
+
// on this outbound wire.
|
|
695
|
+
const fwdHeaders: Record<string, string> = {};
|
|
397
696
|
req.headers.forEach((v, k) => {
|
|
398
|
-
if (!STRIP_FORWARD_HEADERS.has(k.toLowerCase())) fwdHeaders.
|
|
697
|
+
if (!STRIP_FORWARD_HEADERS.has(k.toLowerCase())) fwdHeaders[k.toLowerCase()] = v;
|
|
399
698
|
});
|
|
400
|
-
|
|
401
|
-
for (const [k, v] of Object.entries(inject(req, secretValue))) {
|
|
402
|
-
fwdHeaders.set(k, v);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
699
|
+
for (const [k, v] of Object.entries(brokered.headers)) fwdHeaders[k] = v;
|
|
405
700
|
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
701
|
+
const search = url.searchParams.toString();
|
|
702
|
+
const pathWithQuery = reqLike.path + (search ? `?${search}` : "");
|
|
703
|
+
|
|
704
|
+
let upstream: UpstreamResponse;
|
|
705
|
+
try {
|
|
706
|
+
upstream = await forwardToRealUpstream({
|
|
707
|
+
ip: ips[0],
|
|
708
|
+
host,
|
|
709
|
+
method: reqLike.method,
|
|
710
|
+
pathWithQuery,
|
|
711
|
+
headers: fwdHeaders,
|
|
712
|
+
body: rawBody,
|
|
713
|
+
});
|
|
714
|
+
} catch (err) {
|
|
715
|
+
return new Response(
|
|
716
|
+
`replayFake(${opts.name}): forward to real ${host} (${ips[0]}) failed: ${(err as Error).message}\n`,
|
|
717
|
+
{ status: 599, headers: { "content-type": "text/plain" } },
|
|
718
|
+
);
|
|
719
|
+
}
|
|
413
720
|
|
|
414
721
|
// Decode + redact for storage; return the real (un-redacted) bytes to
|
|
415
722
|
// the app so the manual session behaves like production.
|
|
416
|
-
const redact = makeRedactor(
|
|
417
|
-
const decoded = decodeBody(
|
|
723
|
+
const redact = makeRedactor(brokered.secrets, opts.redactPatterns);
|
|
724
|
+
const decoded = decodeBody(upstream.bytes);
|
|
418
725
|
const respHeaders: Record<string, string> = {};
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
});
|
|
726
|
+
for (const [k, v] of Object.entries(upstream.headers)) {
|
|
727
|
+
if (SENSITIVE_HEADERS.has(k) || STRIP_FORWARD_HEADERS.has(k)) continue;
|
|
728
|
+
respHeaders[k] = v;
|
|
729
|
+
}
|
|
424
730
|
|
|
425
731
|
const redactedQuery: Record<string, string[]> = {};
|
|
426
732
|
for (const [k, vs] of Object.entries(reqLike.query)) {
|
|
@@ -437,20 +743,22 @@ export function replayFake(
|
|
|
437
743
|
: {}),
|
|
438
744
|
},
|
|
439
745
|
response: {
|
|
440
|
-
status:
|
|
746
|
+
status: upstream.status,
|
|
441
747
|
headers: respHeaders,
|
|
442
748
|
body: decoded.bodyEncoding === "utf8" ? redact(decoded.body) : decoded.body,
|
|
443
749
|
bodyEncoding: decoded.bodyEncoding,
|
|
444
750
|
},
|
|
445
751
|
};
|
|
446
752
|
|
|
447
|
-
// Fail-closed: never persist a cassette that still carries
|
|
448
|
-
// secret (a missed redaction is a leak, not a warning).
|
|
449
|
-
|
|
450
|
-
|
|
753
|
+
// Fail-closed: never persist a cassette that still carries any raw
|
|
754
|
+
// secret value (a missed redaction is a leak, not a warning).
|
|
755
|
+
const serialized = JSON.stringify(interaction);
|
|
756
|
+
for (const { ref, value } of brokered.secrets) {
|
|
757
|
+
if (value.length > 0 && serialized.includes(value)) {
|
|
451
758
|
throw new Error(
|
|
452
|
-
`replayFake(${opts.name}): refusing to record — the
|
|
453
|
-
`into the interaction for ${reqLike.method} ${reqLike.path}.
|
|
759
|
+
`replayFake(${opts.name}): refusing to record — the value for secret ${JSON.stringify(ref)} ` +
|
|
760
|
+
`survived redaction into the interaction for ${reqLike.method} ${reqLike.path}. ` +
|
|
761
|
+
`Add a redactPatterns entry.`,
|
|
454
762
|
);
|
|
455
763
|
}
|
|
456
764
|
}
|
|
@@ -458,8 +766,10 @@ export function replayFake(
|
|
|
458
766
|
state.recorded.push(interaction);
|
|
459
767
|
state.dirty = true;
|
|
460
768
|
|
|
461
|
-
|
|
462
|
-
|
|
769
|
+
return new Response(upstream.bytes, {
|
|
770
|
+
status: upstream.status,
|
|
771
|
+
headers: new Headers(respHeaders),
|
|
772
|
+
});
|
|
463
773
|
}
|
|
464
774
|
|
|
465
775
|
// ── handler ─────────────────────────────────────────────────────────
|
|
@@ -479,27 +789,51 @@ export function replayFake(
|
|
|
479
789
|
};
|
|
480
790
|
|
|
481
791
|
if (resolveMode() === "replay") return replay(reqLike, state);
|
|
482
|
-
return record(req, reqLike, rawBody, state);
|
|
792
|
+
return record(req, url, reqLike, rawBody, state);
|
|
483
793
|
};
|
|
484
794
|
|
|
485
795
|
const def: FakeDefinition<CassetteState, ReplayHelpers> = {
|
|
486
796
|
name: opts.name,
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
secretRefs
|
|
797
|
+
// Fake = real: we answer for the real host on :80 and :443.
|
|
798
|
+
hostnames: [host],
|
|
799
|
+
secretRefs,
|
|
800
|
+
// Cassettes are NOT auto-loaded — a fork starts with an empty replay
|
|
801
|
+
// set, so a test that loads nothing fails loud (599). Tests call
|
|
802
|
+
// `replay(...)` to populate it.
|
|
490
803
|
state: (): CassetteState => ({
|
|
491
|
-
cassette:
|
|
804
|
+
cassette: emptyCassette(opts.name, host),
|
|
492
805
|
cursor: new Map(),
|
|
493
806
|
recorded: [],
|
|
494
807
|
dirty: false,
|
|
495
808
|
}),
|
|
496
809
|
handler,
|
|
497
810
|
helpers: ({ state }): ReplayHelpers => ({
|
|
811
|
+
replay(file: string | Cassette): ReplaySummary {
|
|
812
|
+
let loaded: Cassette;
|
|
813
|
+
let label: string;
|
|
814
|
+
if (file && typeof file === "object") {
|
|
815
|
+
loaded = coerceCassette(file, opts.name, host);
|
|
816
|
+
label = file.fake || "(inline)";
|
|
817
|
+
} else if (typeof file === "string" && file.length > 0) {
|
|
818
|
+
label = file;
|
|
819
|
+
loaded = readCassetteFile(opts.name, file, host);
|
|
820
|
+
} else {
|
|
821
|
+
throw new Error(
|
|
822
|
+
`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
|
|
823
|
+
`(e.g. "recordings/${opts.name}.json").`,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
// Mutate the live state object (the handler reads the same
|
|
827
|
+
// reference); reset the cursor so replay starts from the top.
|
|
828
|
+
state.cassette = loaded;
|
|
829
|
+
state.cursor = new Map();
|
|
830
|
+
return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
|
|
831
|
+
},
|
|
498
832
|
dump(): Cassette {
|
|
499
833
|
return {
|
|
500
834
|
version: 1,
|
|
501
835
|
fake: opts.name,
|
|
502
|
-
|
|
836
|
+
host,
|
|
503
837
|
interactions: [...state.cassette.interactions, ...state.recorded],
|
|
504
838
|
};
|
|
505
839
|
},
|
package/src/daemon.ts
CHANGED
|
@@ -3016,11 +3016,27 @@ function installFetchWrapper(): () => void {
|
|
|
3016
3016
|
};
|
|
3017
3017
|
}
|
|
3018
3018
|
|
|
3019
|
-
|
|
3019
|
+
/** Build the `docker exec` argv for a service command. An optional `cwd`
|
|
3020
|
+
* becomes `-w <cwd>` (the working-directory option of `ctx.exec`), so the
|
|
3021
|
+
* command runs from that directory without it being baked into the command
|
|
3022
|
+
* string. Used by both the buffered and streaming variants so they stay in
|
|
3023
|
+
* lockstep. */
|
|
3024
|
+
function dockerExecArgs(service: string, command: string, cwd?: string): string[] {
|
|
3025
|
+
const args = ["exec"];
|
|
3026
|
+
if (cwd) args.push("-w", cwd);
|
|
3027
|
+
args.push(service, "sh", "-lc", command);
|
|
3028
|
+
return args;
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
function execInService(
|
|
3032
|
+
service: string,
|
|
3033
|
+
command: string,
|
|
3034
|
+
cwd?: string,
|
|
3035
|
+
): Promise<ExecResult> {
|
|
3020
3036
|
return new Promise((resolve) => {
|
|
3021
3037
|
execFile(
|
|
3022
3038
|
"docker",
|
|
3023
|
-
|
|
3039
|
+
dockerExecArgs(service, command, cwd),
|
|
3024
3040
|
{ maxBuffer: 16 * 1024 * 1024 },
|
|
3025
3041
|
(err, stdout, stderr) => {
|
|
3026
3042
|
const exitCode =
|
|
@@ -3046,8 +3062,9 @@ function execInService(service: string, command: string): Promise<ExecResult> {
|
|
|
3046
3062
|
async function execInServiceWrapped(
|
|
3047
3063
|
service: string,
|
|
3048
3064
|
command: string,
|
|
3065
|
+
opts?: { cwd?: string },
|
|
3049
3066
|
): Promise<Wrapped<ExecResult>> {
|
|
3050
|
-
const res = await execInService(service, command);
|
|
3067
|
+
const res = await execInService(service, command, opts?.cwd);
|
|
3051
3068
|
return wrap(res, undefined) as unknown as Wrapped<ExecResult>;
|
|
3052
3069
|
}
|
|
3053
3070
|
|
|
@@ -3074,9 +3091,10 @@ function execInServiceStreaming(
|
|
|
3074
3091
|
service: string,
|
|
3075
3092
|
command: string,
|
|
3076
3093
|
onChunk: (stream: "stdout" | "stderr", data: string) => void,
|
|
3094
|
+
cwd?: string,
|
|
3077
3095
|
): Promise<ExecResult> {
|
|
3078
3096
|
return new Promise((resolve) => {
|
|
3079
|
-
const child = spawn("docker",
|
|
3097
|
+
const child = spawn("docker", dockerExecArgs(service, command, cwd), {
|
|
3080
3098
|
stdio: ["ignore", "pipe", "pipe"],
|
|
3081
3099
|
});
|
|
3082
3100
|
const acc = { stdout: "", stderr: "" };
|
|
@@ -3267,8 +3285,13 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3267
3285
|
// or animated output replays with real timing in the web UI. The
|
|
3268
3286
|
// frames are presentation-only; the ExecResult (and any assertions on
|
|
3269
3287
|
// it) still sees the plain separated stdout/stderr.
|
|
3270
|
-
const recordedExec = async (
|
|
3288
|
+
const recordedExec = async (
|
|
3289
|
+
service: string,
|
|
3290
|
+
command: string,
|
|
3291
|
+
opts?: { cwd?: string },
|
|
3292
|
+
): Promise<ExecResult> => {
|
|
3271
3293
|
const t = Date.now();
|
|
3294
|
+
const cwd = opts?.cwd;
|
|
3272
3295
|
const resv = reserveEvent();
|
|
3273
3296
|
const session = newTerminalSession(
|
|
3274
3297
|
start,
|
|
@@ -3280,8 +3303,11 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3280
3303
|
);
|
|
3281
3304
|
terminalSessions.push(session.record);
|
|
3282
3305
|
// Synthetic prompt frame so the replay is self-describing — the
|
|
3283
|
-
// program's own output starts on the next line, like a real shell.
|
|
3284
|
-
|
|
3306
|
+
// program's own output starts on the next line, like a real shell. A
|
|
3307
|
+
// working directory rides in the prompt sigil (`svc:/dir $`) the way a
|
|
3308
|
+
// real shell prompt shows it, so the recording stays self-describing.
|
|
3309
|
+
const sigil = cwd ? `${service}:${cwd}` : service;
|
|
3310
|
+
session.pushFrame(0, `\x1b[32m${sigil} $\x1b[0m \x1b[1m${command}\x1b[0m\r\n`);
|
|
3285
3311
|
let frameBytes = 0;
|
|
3286
3312
|
let frameCapped = false;
|
|
3287
3313
|
const res = await execInServiceStreaming(service, command, (_stream, data) => {
|
|
@@ -3302,13 +3328,14 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3302
3328
|
// `\r\n` too keeps a CR|LF split across chunk boundaries
|
|
3303
3329
|
// harmless (`\r\r\n` renders identically).
|
|
3304
3330
|
session.pushFrame((Date.now() - t) / 1000, data.replace(/\r?\n/g, "\r\n"));
|
|
3305
|
-
});
|
|
3331
|
+
}, cwd);
|
|
3306
3332
|
session.markClosed();
|
|
3307
3333
|
const stdout = truncateUtf8(res.stdout);
|
|
3308
3334
|
const stderr = truncateUtf8(res.stderr);
|
|
3309
3335
|
const seq = recordExec({
|
|
3310
3336
|
service,
|
|
3311
3337
|
command,
|
|
3338
|
+
cwd,
|
|
3312
3339
|
exitCode: res.exitCode,
|
|
3313
3340
|
stdout: stdout.value,
|
|
3314
3341
|
stdoutTruncated: stdout.truncated,
|
package/src/index.ts
CHANGED
|
@@ -572,8 +572,17 @@ export interface TestContext<
|
|
|
572
572
|
* program sees plain pipes (`isatty` false), so stdout/stderr stay
|
|
573
573
|
* byte-identical to what `exec` always returned and TTY-gated
|
|
574
574
|
* spinners/colour won't be emitted — reach for `terminal` when the
|
|
575
|
-
* CLI needs to believe it's on a TTY.
|
|
576
|
-
|
|
575
|
+
* CLI needs to believe it's on a TTY.
|
|
576
|
+
*
|
|
577
|
+
* Pass `{ cwd }` to run the command from a working directory instead of
|
|
578
|
+
* prefixing it with `cd <dir> && ` — the cwd is kept off the command
|
|
579
|
+
* string, so the timeline sidebar shows just the command and the
|
|
580
|
+
* directory surfaces in the detail view. */
|
|
581
|
+
exec(
|
|
582
|
+
service: string,
|
|
583
|
+
command: string,
|
|
584
|
+
opts?: ExecOpts,
|
|
585
|
+
): Promise<Wrapped<ExecResult>>;
|
|
577
586
|
/**
|
|
578
587
|
* Run a command inside a service container under a PTY and record the
|
|
579
588
|
* full terminal session as an asciicast for replay in the web UI.
|
|
@@ -756,6 +765,20 @@ export interface ExecResult {
|
|
|
756
765
|
exitCode: number;
|
|
757
766
|
}
|
|
758
767
|
|
|
768
|
+
/** Options for {@link TestContext.exec}. */
|
|
769
|
+
export interface ExecOpts {
|
|
770
|
+
/**
|
|
771
|
+
* Working directory inside the container to run the command from.
|
|
772
|
+
* Equivalent to prefixing the command with `cd <cwd> && `, but the
|
|
773
|
+
* directory is kept off the command string: the recorded step's
|
|
774
|
+
* sidebar summary shows just the command, while the working directory
|
|
775
|
+
* is surfaced in the detail view (the prompt line of the captured
|
|
776
|
+
* terminal and the step's panel header). Implemented as `docker exec
|
|
777
|
+
* -w <cwd>`, so a relative path resolves against the image's WORKDIR.
|
|
778
|
+
*/
|
|
779
|
+
cwd?: string;
|
|
780
|
+
}
|
|
781
|
+
|
|
759
782
|
export interface TestSuite<
|
|
760
783
|
S extends ServicesMap = ServicesMap,
|
|
761
784
|
F extends FakesMap = FakesMap,
|
|
@@ -997,7 +1020,11 @@ export interface ProjectSetupContext<
|
|
|
997
1020
|
F extends FakesMap = FakesMap,
|
|
998
1021
|
> {
|
|
999
1022
|
fetch: SpectestFetch;
|
|
1000
|
-
exec(
|
|
1023
|
+
exec(
|
|
1024
|
+
service: string,
|
|
1025
|
+
command: string,
|
|
1026
|
+
opts?: ExecOpts,
|
|
1027
|
+
): Promise<Wrapped<ExecResult>>;
|
|
1001
1028
|
readonly svc: ServiceHandlesFor<S>;
|
|
1002
1029
|
/** Same surface tests see — fakes are already up by setup time. Typed
|
|
1003
1030
|
* against the declared fakes map (see {@link TestContext.fakes}). */
|
|
@@ -1341,6 +1368,15 @@ interface Transforms {
|
|
|
1341
1368
|
/** URL-decode the value, mirroring `decodeURIComponent(x)`. Chains after
|
|
1342
1369
|
* `base64Decoded()` for base64-then-URL-encoded values. */
|
|
1343
1370
|
urlDecoded(): Expectation;
|
|
1371
|
+
/** Parse the value as JSON (`JSON.parse(x)`) and assert on the result with the
|
|
1372
|
+
* full matcher vocabulary — `toEqual` against the decoded object/array,
|
|
1373
|
+
* `toContain`/`toHaveLength` against a decoded array. Throws-as-failed-assertion
|
|
1374
|
+
* if the value isn't a string or isn't valid JSON. Chains after other
|
|
1375
|
+
* transforms (e.g. `base64Decoded().jsonDecoded()` for a base64-wrapped JSON
|
|
1376
|
+
* payload). The optional type parameter `T` annotates the decoded shape at the
|
|
1377
|
+
* call site (`jsonDecoded<User>()`) — it casts the parsed value, but, like the
|
|
1378
|
+
* matchers, is not enforced at runtime. */
|
|
1379
|
+
jsonDecoded<T = unknown>(): Expectation;
|
|
1344
1380
|
/**
|
|
1345
1381
|
* Generic escape hatch: apply an arbitrary `fn` to the raw value and assert on
|
|
1346
1382
|
* the result, keeping provenance. `label` is a plain word (`"json"`,
|
package/src/recorder.ts
CHANGED
|
@@ -47,6 +47,12 @@ export interface ExecEvent extends BaseEvent {
|
|
|
47
47
|
kind: "exec";
|
|
48
48
|
service: string;
|
|
49
49
|
command: string;
|
|
50
|
+
/**
|
|
51
|
+
* Working directory the command ran from (`ctx.exec(svc, cmd, { cwd })`),
|
|
52
|
+
* if any. Kept off `command` so the sidebar shows just the command; the
|
|
53
|
+
* detail view surfaces it. Absent when the call used no `cwd`.
|
|
54
|
+
*/
|
|
55
|
+
cwd?: string;
|
|
50
56
|
exitCode: number;
|
|
51
57
|
/** stdout truncated to OUTPUT_SNIPPET_BYTES. */
|
|
52
58
|
stdout: string;
|