@jterrazz/test 12.1.0 → 13.0.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.
@@ -0,0 +1,1726 @@
1
+ import { i as SpecSyntaxError, l as updateSpecFile, s as readSpecFile } from "./spec-document.js";
2
+ import { a as copyPlan, n as Matcher, r as TOKEN_KINDS, t as CaptureScope } from "./match.js";
3
+ import { cpSync, existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { execSync, spawn } from "node:child_process";
6
+ import { readdir } from "node:fs/promises";
7
+ import { fileURLToPath } from "node:url";
8
+ import { createServer } from "node:net";
9
+ //#region src/core/matching/structural.ts
10
+ /**
11
+ * Structural comparison engine shared by every fixture matcher.
12
+ *
13
+ * Handles three kinds of "expected" values:
14
+ * - plain JSON values → strict deep equality
15
+ * - {@link Matcher} instances (code-side `match.*`)
16
+ * - strings containing `{{placeholder}}` forms (file-side fixtures)
17
+ *
18
+ * One unified `{{token}}` grammar (CONVENTIONS D4) — the same vocabulary
19
+ * works in `expected/*.http` (body and headers), `expected/*.json`, and
20
+ * text snapshots (`expected/*.txt`).
21
+ *
22
+ * Ref captures (`match.ref(name)` / `{{type#name}}`) are recorded in the
23
+ * {@link CaptureScope} supplied by the caller.
24
+ */
25
+ const UUID_SOURCE = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
26
+ const ULID_SOURCE = "[0-9A-HJKMNP-TV-Z]{26}";
27
+ const ISO8601_SOURCE = String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})`;
28
+ const DATE_SOURCE = String.raw`\d{4}-\d{2}-\d{2}`;
29
+ const TIME_SOURCE = String.raw`\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?`;
30
+ const DURATION_SOURCE = String.raw`\d+(?:\.\d+)?(?:ms|s|m|h)`;
31
+ const NUMBER_SOURCE = String.raw`-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`;
32
+ const INT_SOURCE = String.raw`-?\d+`;
33
+ const FLOAT_SOURCE = String.raw`-?\d+\.\d+`;
34
+ const SEMVER_SOURCE = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`;
35
+ const SHA_SOURCE = "[0-9a-f]{7,64}";
36
+ const HEX_SOURCE = "[0-9a-fA-F]+";
37
+ const BASE64_SOURCE = "[A-Za-z0-9+/]+={0,2}";
38
+ const PORT_SOURCE = String.raw`(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4})`;
39
+ const IP_OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
40
+ const IP_SOURCE = String.raw`(?:${IP_OCTET}\.){3}${IP_OCTET}`;
41
+ const URL_SOURCE = String.raw`https?:\/\/[^\s"'<>]+`;
42
+ const EMBEDDED_SOURCES = {
43
+ base64: BASE64_SOURCE,
44
+ date: DATE_SOURCE,
45
+ duration: DURATION_SOURCE,
46
+ email: String.raw`[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+`,
47
+ float: FLOAT_SOURCE,
48
+ hex: HEX_SOURCE,
49
+ int: INT_SOURCE,
50
+ ip: IP_SOURCE,
51
+ iso8601: ISO8601_SOURCE,
52
+ number: NUMBER_SOURCE,
53
+ path: String.raw`\.{0,2}\/[^\s"'<>]*`,
54
+ port: PORT_SOURCE,
55
+ semver: SEMVER_SOURCE,
56
+ sha: SHA_SOURCE,
57
+ time: TIME_SOURCE,
58
+ ulid: ULID_SOURCE,
59
+ url: URL_SOURCE,
60
+ uuid: UUID_SOURCE
61
+ };
62
+ const wholeRe = (source) => new RegExp(`^(?:${source})$`);
63
+ const WHOLE_RES = Object.fromEntries(Object.entries(EMBEDDED_SOURCES).map(([kind, source]) => [kind, wholeRe(source)]));
64
+ const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "g");
65
+ /** Whether a fixture string contains at least one `{{placeholder}}`. */
66
+ function hasPlaceholders(value) {
67
+ PLACEHOLDER_RE.lastIndex = 0;
68
+ return PLACEHOLDER_RE.test(value);
69
+ }
70
+ /** Key-order-independent stringification for captured-object comparison. */
71
+ function stableStringify(value) {
72
+ if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
73
+ if (value !== null && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : 1).map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`;
74
+ return JSON.stringify(value) ?? "undefined";
75
+ }
76
+ function capturedEquals(a, b) {
77
+ if (Object.is(a, b)) return true;
78
+ if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return String(a) === String(b);
79
+ if (a !== null && b !== null && typeof a === "object" && typeof b === "object") return stableStringify(a) === stableStringify(b);
80
+ return false;
81
+ }
82
+ function recordRef(name, actual, scope) {
83
+ if (scope.has(name)) return capturedEquals(scope.get(name), actual);
84
+ scope.set(name, actual);
85
+ return true;
86
+ }
87
+ function isPortValue(value) {
88
+ return Number.isInteger(value) && value >= 0 && value <= 65535;
89
+ }
90
+ function kindMatches(kind, actual, scope) {
91
+ switch (kind) {
92
+ case "any": return true;
93
+ case "float":
94
+ if (typeof actual === "number") return Number.isFinite(actual);
95
+ return typeof actual === "string" && WHOLE_RES.float.test(actual);
96
+ case "int":
97
+ if (typeof actual === "number") return Number.isInteger(actual);
98
+ return typeof actual === "string" && WHOLE_RES.int.test(actual);
99
+ case "number":
100
+ if (typeof actual === "number") return Number.isFinite(actual);
101
+ return typeof actual === "string" && WHOLE_RES.number.test(actual);
102
+ case "port":
103
+ if (typeof actual === "number") return isPortValue(actual);
104
+ return typeof actual === "string" && WHOLE_RES.port.test(actual) && isPortValue(Number(actual));
105
+ case "string": return typeof actual === "string";
106
+ case "workdir": return typeof actual === "string" && scope.workdir !== void 0 ? actual === scope.workdir : false;
107
+ default: {
108
+ const re = WHOLE_RES[kind];
109
+ return re !== void 0 && typeof actual === "string" && re.test(actual);
110
+ }
111
+ }
112
+ }
113
+ function matcherMatches(matcher, actual, scope) {
114
+ if (matcher.kind === "includes") return typeof actual === "string" && actual.includes(matcher.includes);
115
+ if (matcher.kind === "regex") return typeof actual === "string" && matcher.regex.test(actual);
116
+ if (matcher.kind === "ref") {
117
+ if (matcher.notRef && scope.has(matcher.notRef) && capturedEquals(scope.get(matcher.notRef), actual)) return false;
118
+ return recordRef(matcher.refName, actual, scope);
119
+ }
120
+ return kindMatches(matcher.kind, actual, scope);
121
+ }
122
+ function placeholderSource(kind, scope) {
123
+ if (kind === "workdir") return scope.workdir === void 0 ? "(?!)" : escapeRegExp(scope.workdir);
124
+ const source = EMBEDDED_SOURCES[kind];
125
+ if (source) return source;
126
+ return kind === "any" ? String.raw`[\s\S]*?` : String.raw`[^\n]*?`;
127
+ }
128
+ function escapeRegExp(text) {
129
+ return text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
130
+ }
131
+ function parsePlaceholderString(expected, scope) {
132
+ PLACEHOLDER_RE.lastIndex = 0;
133
+ const refs = [];
134
+ let source = "";
135
+ let lastIndex = 0;
136
+ let single = null;
137
+ let count = 0;
138
+ for (const found of expected.matchAll(PLACEHOLDER_RE)) {
139
+ const kind = found.groups.kind;
140
+ const ref = found.groups.ref;
141
+ source += escapeRegExp(expected.slice(lastIndex, found.index));
142
+ source += `(${placeholderSource(kind, scope)})`;
143
+ refs.push({
144
+ index: count,
145
+ kind,
146
+ ref
147
+ });
148
+ count++;
149
+ lastIndex = found.index + found[0].length;
150
+ if (found.index === 0 && found[0].length === expected.length) single = {
151
+ kind,
152
+ ref
153
+ };
154
+ }
155
+ source += escapeRegExp(expected.slice(lastIndex));
156
+ return {
157
+ pattern: new RegExp(`^${source}$`),
158
+ refs,
159
+ single,
160
+ source
161
+ };
162
+ }
163
+ /**
164
+ * Unanchored regex source matching a fixture string — plain text escaped
165
+ * verbatim, each `{{token}}` expanded to its embedded grammar. Used to route
166
+ * declared `.http` intercept paths (`/articles/{{uuid}}`) as URL patterns.
167
+ */
168
+ function placeholderPatternSource(expected) {
169
+ return parsePlaceholderString(expected, new CaptureScope()).source;
170
+ }
171
+ function placeholderStringMatches(expected, actual, scope) {
172
+ const parsed = parsePlaceholderString(expected, scope);
173
+ if (parsed.single) {
174
+ if (!kindMatches(parsed.single.kind, actual, scope)) return false;
175
+ if (parsed.single.ref) return recordRef(parsed.single.ref, actual, scope);
176
+ return true;
177
+ }
178
+ if (typeof actual !== "string" && typeof actual !== "number") return false;
179
+ const text = String(actual);
180
+ const found = parsed.pattern.exec(text);
181
+ if (!found) return false;
182
+ for (const entry of parsed.refs) {
183
+ if (!entry.ref) continue;
184
+ if (!recordRef(entry.ref, found[entry.index + 1], scope)) return false;
185
+ }
186
+ return true;
187
+ }
188
+ function isPlainObject(value) {
189
+ return value !== null && typeof value === "object" && !Array.isArray(value);
190
+ }
191
+ /**
192
+ * Deep structural equality with matcher / placeholder support. Ref captures
193
+ * are recorded in `scope` in traversal order (arrays left-to-right, object
194
+ * keys in expected-key order).
195
+ */
196
+ function structuralEquals(expected, actual, scope) {
197
+ if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
198
+ if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
199
+ if (Array.isArray(expected)) {
200
+ if (!Array.isArray(actual) || actual.length !== expected.length) return false;
201
+ return expected.every((item, i) => structuralEquals(item, actual[i], scope));
202
+ }
203
+ if (isPlainObject(expected)) {
204
+ if (!isPlainObject(actual)) return false;
205
+ const expectedKeys = Object.keys(expected);
206
+ const actualKeys = Object.keys(actual);
207
+ if (expectedKeys.length !== actualKeys.length) return false;
208
+ return expectedKeys.every((key) => key in actual && structuralEquals(expected[key], actual[key], scope));
209
+ }
210
+ return Object.is(expected, actual);
211
+ }
212
+ /**
213
+ * Deep structural SUBSET match (toMatchObject-style) with matcher /
214
+ * placeholder support. Plain objects match when every expected key is present
215
+ * and recursively subset-matches — the actual value may carry extra keys.
216
+ * Arrays require equal length with each element subset-matched. Leaves fall
217
+ * back to {@link structuralEquals} semantics (matchers, placeholders, strict
218
+ * equality). Used by request-body filters on intercept triggers.
219
+ */
220
+ function structuralSubset(expected, actual, scope) {
221
+ if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
222
+ if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
223
+ if (Array.isArray(expected)) {
224
+ if (!Array.isArray(actual) || actual.length !== expected.length) return false;
225
+ return expected.every((item, i) => structuralSubset(item, actual[i], scope));
226
+ }
227
+ if (isPlainObject(expected)) {
228
+ if (!isPlainObject(actual)) return false;
229
+ return Object.keys(expected).every((key) => key in actual && structuralSubset(expected[key], actual[key], scope));
230
+ }
231
+ return Object.is(expected, actual);
232
+ }
233
+ /**
234
+ * Multi-line text comparison with `{{token}}` support — used by text
235
+ * snapshots (`expected/*.txt`). Without placeholders this is strict equality.
236
+ */
237
+ function textEquals(expected, actual, scope) {
238
+ if (!hasPlaceholders(expected)) return expected === actual;
239
+ return placeholderStringMatches(expected, actual, scope);
240
+ }
241
+ /**
242
+ * Substring comparison with `{{token}}` support — the `contains` half of the
243
+ * `files:` assertions of a spec document. Without placeholders this is
244
+ * `String.includes`; with them the fixture text becomes an UNANCHORED pattern,
245
+ * so `wrote {{path}}` is found inside a larger file, and its `#ref` captures are
246
+ * recorded exactly as a whole-text comparison records them.
247
+ */
248
+ function textContains(expected, actual, scope) {
249
+ if (!hasPlaceholders(expected)) return actual.includes(expected);
250
+ const parsed = parsePlaceholderString(expected, scope);
251
+ const found = new RegExp(parsed.source).exec(actual);
252
+ if (!found) return false;
253
+ return parsed.refs.every((entry) => !entry.ref || recordRef(entry.ref, found[entry.index + 1], scope));
254
+ }
255
+ /**
256
+ * Render an expected value for failure diffs and serialization: Matcher
257
+ * instances become their placeholder text, everything else is untouched.
258
+ */
259
+ function renderExpected(value) {
260
+ if (value instanceof Matcher) return value.toString();
261
+ if (Array.isArray(value)) return value.map((item) => renderExpected(item));
262
+ if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderExpected(item)]));
263
+ return value;
264
+ }
265
+ /**
266
+ * Substitute the framework-known cwd (`workdir`) for its `{{workdir}}` token in
267
+ * every string leaf of a structured value. The structural mirror of the text
268
+ * path's `actual.replaceAll(workdir, '{{workdir}}')` — so a golden written under
269
+ * `TEST_UPDATE=1` stores the token, not a run-specific temp path (CONVENTIONS
270
+ * D5). A no-op when `workdir` is undefined (no cwd, e.g. api/jobs mode).
271
+ */
272
+ function substituteWorkdirDeep(value, workdir) {
273
+ if (typeof value === "string") return value.replaceAll(workdir, "{{workdir}}");
274
+ if (Array.isArray(value)) return value.map((item) => substituteWorkdirDeep(item, workdir));
275
+ if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteWorkdirDeep(item, workdir)]));
276
+ return value;
277
+ }
278
+ /**
279
+ * Update-mode merge: rewrite a fixture from the actual output while
280
+ * preserving every segment of the previous fixture that was covered by a
281
+ * placeholder (and still matches the actual value).
282
+ *
283
+ * Token preservation is symmetric with the text path (CONVENTIONS D5): a
284
+ * previous `{{placeholder}}` (including `{{workdir}}`) that still matches the
285
+ * RAW actual value is kept; every other leaf is taken from the actual output
286
+ * with the framework-known cwd substituted back to `{{workdir}}`.
287
+ */
288
+ function mergePreservingPlaceholders(previous, actual, workdir) {
289
+ const scope = new CaptureScope(workdir);
290
+ const subst = (value) => workdir === void 0 ? value : substituteWorkdirDeep(value, workdir);
291
+ function merge(prev, act) {
292
+ if (typeof prev === "string" && hasPlaceholders(prev)) return placeholderStringMatches(prev, act, scope) ? prev : subst(act);
293
+ if (Array.isArray(prev) && Array.isArray(act)) return act.map((item, i) => i < prev.length ? merge(prev[i], item) : subst(item));
294
+ if (isPlainObject(prev) && isPlainObject(act)) return Object.fromEntries(Object.entries(act).map(([key, item]) => [key, key in prev ? merge(prev[key], item) : subst(item)]));
295
+ return subst(act);
296
+ }
297
+ return merge(previous, actual);
298
+ }
299
+ /**
300
+ * Update-mode merge for text snapshots: a previous line whose placeholders
301
+ * still match an actual line is preserved; every other line is taken from the
302
+ * actual output. Values the framework knows to be dynamic (`{{workdir}}`) are
303
+ * substituted automatically (CONVENTIONS D5).
304
+ *
305
+ * Pairing is by **pattern, not by line index**. The aligned line is tried
306
+ * first, so a golden that still lines up comes back byte-identical; what is
307
+ * left over is then re-paired by matching each remaining previous placeholder
308
+ * line against each unresolved actual line, in file order. That is what makes a
309
+ * MID-FILE INSERTION safe: inserting a line shifts everything below it, and the
310
+ * shifted `{{duration}}` / `{{uuid}}` lines are recognised where they landed
311
+ * instead of being rewritten as raw values. Each previous line is used at most
312
+ * once, so two identical placeholder lines cannot collapse into one.
313
+ */
314
+ function mergeTextPreservingPlaceholders(previous, actual, scope) {
315
+ const substituted = scope.workdir === void 0 ? actual : actual.replaceAll(scope.workdir, "{{workdir}}");
316
+ if (previous === null) return substituted;
317
+ const prevLines = previous.split("\n");
318
+ const actualLines = substituted.split("\n");
319
+ const rawLines = actual.split("\n");
320
+ const consumed = /* @__PURE__ */ new Set();
321
+ const covers = (prevIndex, actualIndex) => {
322
+ const prev = prevLines[prevIndex];
323
+ if (prev === void 0 || !hasPlaceholders(prev)) return false;
324
+ const line = actualLines[actualIndex];
325
+ return textEquals(prev, line, new CaptureScope(scope.workdir)) || textEquals(prev, rawLines[actualIndex] ?? line, new CaptureScope(scope.workdir));
326
+ };
327
+ const merged = actualLines.map(() => void 0);
328
+ for (const [index] of actualLines.entries()) if (covers(index, index)) {
329
+ merged[index] = prevLines[index];
330
+ consumed.add(index);
331
+ }
332
+ for (const [index, line] of actualLines.entries()) {
333
+ if (merged[index] !== void 0) continue;
334
+ for (const [prevIndex] of prevLines.entries()) {
335
+ if (consumed.has(prevIndex) || !covers(prevIndex, index)) continue;
336
+ merged[index] = prevLines[prevIndex];
337
+ consumed.add(prevIndex);
338
+ break;
339
+ }
340
+ merged[index] ??= line;
341
+ }
342
+ return merged.join("\n");
343
+ }
344
+ //#endregion
345
+ //#region src/core/specification/shared/reporter.ts
346
+ const GREEN = "\x1B[32m";
347
+ const RED = "\x1B[31m";
348
+ const DIM = "\x1B[2m";
349
+ const BOLD = "\x1B[1m";
350
+ const RESET = "\x1B[0m";
351
+ const BG_CYAN = "\x1B[46m";
352
+ const BLACK = "\x1B[30m";
353
+ const CHECK = "✓";
354
+ const CROSS = "×";
355
+ const ARROW = "→";
356
+ function formatStartupReport(mode, services, app) {
357
+ const lines = [];
358
+ lines.push("");
359
+ lines.push(`${BG_CYAN}${BLACK}${BOLD} INFRA ${RESET} Starting infrastructure...`);
360
+ lines.push("");
361
+ for (const service of services) if (service.error) {
362
+ lines.push(` ${RED}${CROSS}${RESET} ${service.type} (${service.name}) ${RED}${service.error}${RESET} ${DIM}${service.durationMs}ms${RESET}`);
363
+ if (service.logs) {
364
+ const logLines = service.logs.trim().split("\n").slice(-10);
365
+ for (const logLine of logLines) lines.push(` ${DIM}${logLine}${RESET}`);
366
+ }
367
+ } else {
368
+ const conn = service.connectionString ? `${DIM}${service.connectionString}${RESET}` : "";
369
+ lines.push(` ${GREEN}${CHECK}${RESET} ${service.type} (${service.name}) ${conn} ${DIM}${service.durationMs}ms${RESET}`);
370
+ }
371
+ if (app) {
372
+ lines.push("");
373
+ if (app.type === "in-process") lines.push(` ${DIM}${ARROW} app: in-process (Hono)${RESET}`);
374
+ else lines.push(` ${DIM}${ARROW} app: ${app.url}${RESET}`);
375
+ }
376
+ lines.push("");
377
+ return lines.join("\n");
378
+ }
379
+ function formatTableDiff(table, columns, expected, actual) {
380
+ const lines = [];
381
+ lines.push(`Table "${table}" mismatch`);
382
+ lines.push(`${DIM} query: ${columns.join(", ")}${RESET}`);
383
+ lines.push(`${DIM} expected: ${rowLabel(expected.length)}${RESET}`);
384
+ lines.push(`${DIM} received: ${rowLabel(actual.length)}${RESET}`);
385
+ lines.push("");
386
+ lines.push(`${GREEN}- Expected${RESET}`);
387
+ lines.push(`${RED}+ Received${RESET}`);
388
+ lines.push("");
389
+ const header = columns.join(" | ");
390
+ lines.push(`${DIM} ${header}${RESET}`);
391
+ const maxRows = Math.max(expected.length, actual.length);
392
+ for (let i = 0; i < maxRows; i++) {
393
+ const exp = expected[i];
394
+ const act = actual[i];
395
+ if (exp && !act) lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
396
+ else if (!exp && act) lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
397
+ else if (exp && act) if (JSON.stringify(exp) === JSON.stringify(act)) lines.push(` ${formatRow(act)}`);
398
+ else {
399
+ lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
400
+ lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
401
+ }
402
+ }
403
+ if (expected.length === 0 && actual.length === 0) lines.push(` (empty)`);
404
+ return lines.join("\n");
405
+ }
406
+ function formatResponseDiff(file, expected, actual) {
407
+ const lines = [];
408
+ lines.push(`Response mismatch (${file})`);
409
+ lines.push("");
410
+ lines.push(`${GREEN}- Expected${RESET}`);
411
+ lines.push(`${RED}+ Received${RESET}`);
412
+ lines.push("");
413
+ const expectedLines = JSON.stringify(expected, null, 2).split("\n");
414
+ const actualLines = JSON.stringify(actual, null, 2).split("\n");
415
+ const maxLines = Math.max(expectedLines.length, actualLines.length);
416
+ for (let i = 0; i < maxLines; i++) {
417
+ const exp = expectedLines[i];
418
+ const act = actualLines[i];
419
+ if (exp === act) lines.push(` ${exp}`);
420
+ else {
421
+ if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
422
+ if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
423
+ }
424
+ }
425
+ return lines.join("\n");
426
+ }
427
+ const strictEquals = (expected, actual) => expected === actual;
428
+ function renderLines(expectedLines, actualLines, equals) {
429
+ const rendered = [];
430
+ let marked = 0;
431
+ const maxLines = Math.max(expectedLines.length, actualLines.length);
432
+ for (let i = 0; i < maxLines; i++) {
433
+ const exp = expectedLines[i];
434
+ const act = actualLines[i];
435
+ if (exp !== void 0 && act !== void 0 && equals(exp, act)) {
436
+ rendered.push(` ${exp}`);
437
+ continue;
438
+ }
439
+ marked++;
440
+ if (exp !== void 0) rendered.push(`${GREEN}- ${exp}${RESET}`);
441
+ if (act !== void 0) rendered.push(`${RED}+ ${act}${RESET}`);
442
+ }
443
+ return {
444
+ marked,
445
+ rendered
446
+ };
447
+ }
448
+ /**
449
+ * Render an expected/received diff over two texts.
450
+ *
451
+ * `equals` is how a line is judged EQUAL. Callers that compare through the
452
+ * `{{token}}` grammar pass a token-aware predicate, so a `{{url}}` line that
453
+ * matched is shown as equal — with its token, not as a `-`/`+` pair against
454
+ * the concrete URL. Marking a line the comparison ACCEPTED buries the one it
455
+ * rejected: the reader then has to work out which of the differences is the
456
+ * actual cause.
457
+ *
458
+ * Safety net: if the token-aware pass marks nothing at all, the whole diff
459
+ * would render as a clean block against a failure that really happened (a
460
+ * `#ref` captured inconsistently across lines, say). The strict rendering is
461
+ * used instead, so a diff always shows the reader something.
462
+ */
463
+ function formatStdoutDiff(file, expected, actual, options) {
464
+ const lines = [];
465
+ lines.push(`Output mismatch (${file})`);
466
+ lines.push("");
467
+ lines.push(`${GREEN}- Expected${RESET}`);
468
+ lines.push(`${RED}+ Received${RESET}`);
469
+ lines.push("");
470
+ const expectedLines = expected.split("\n");
471
+ const actualLines = actual.split("\n");
472
+ const equals = options?.equals ?? strictEquals;
473
+ const first = renderLines(expectedLines, actualLines, equals);
474
+ const rendered = first.marked === 0 && equals !== strictEquals ? renderLines(expectedLines, actualLines, strictEquals).rendered : first.rendered;
475
+ return [...lines, ...rendered].join("\n");
476
+ }
477
+ function formatDirectoryDiff(fixtureName, diff, hint) {
478
+ const lines = [];
479
+ const total = diff.added.length + diff.removed.length + diff.changed.length;
480
+ lines.push(`Directory mismatch: ${BOLD}${fixtureName}${RESET}`);
481
+ lines.push(`${DIM} ${total} difference${total === 1 ? "" : "s"}: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed${RESET}`);
482
+ lines.push("");
483
+ lines.push(`${GREEN}- Expected (fixture)${RESET}`);
484
+ lines.push(`${RED}+ Received (generated)${RESET}`);
485
+ lines.push("");
486
+ for (const path of diff.added) lines.push(`${RED}+ added ${path}${RESET} ${DIM}(not in fixture)${RESET}`);
487
+ for (const path of diff.removed) lines.push(`${GREEN}- removed ${path}${RESET} ${DIM}(in fixture, not generated)${RESET}`);
488
+ for (const { path, expected, actual } of diff.changed) {
489
+ const expectedLines = expected.split("\n");
490
+ const actualLines = actual.split("\n");
491
+ const changedCount = countLineDifferences(expectedLines, actualLines);
492
+ lines.push(`${BOLD}~ changed ${path}${RESET} ${DIM}(${changedCount} line${changedCount === 1 ? "" : "s"} differ)${RESET}`);
493
+ let shown = 0;
494
+ const maxShown = 5;
495
+ const maxLines = Math.max(expectedLines.length, actualLines.length);
496
+ for (let i = 0; i < maxLines && shown < maxShown; i++) {
497
+ const exp = expectedLines[i];
498
+ const act = actualLines[i];
499
+ if (exp !== act) {
500
+ lines.push(`${DIM} line ${i + 1}:${RESET}`);
501
+ if (exp !== void 0) lines.push(` ${GREEN}- ${exp}${RESET}`);
502
+ if (act !== void 0) lines.push(` ${RED}+ ${act}${RESET}`);
503
+ shown++;
504
+ }
505
+ }
506
+ if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
507
+ }
508
+ lines.push("");
509
+ lines.push(`${DIM}${hint}${RESET}`);
510
+ return lines.join("\n");
511
+ }
512
+ function countLineDifferences(expected, actual) {
513
+ let count = 0;
514
+ const max = Math.max(expected.length, actual.length);
515
+ for (let i = 0; i < max; i++) if (expected[i] !== actual[i]) count++;
516
+ return count;
517
+ }
518
+ function rowLabel(n) {
519
+ return n === 1 ? "1 row" : `${n} rows`;
520
+ }
521
+ function formatRow(row) {
522
+ return row.map((v) => String(v ?? "null")).join(" | ");
523
+ }
524
+ //#endregion
525
+ //#region src/core/specification/shared/result/directory.ts
526
+ const DEFAULT_IGNORES = [
527
+ ".git",
528
+ ".DS_Store",
529
+ "node_modules",
530
+ ".next",
531
+ "dist",
532
+ ".turbo",
533
+ ".cache"
534
+ ];
535
+ /**
536
+ * Recursively walk a directory, returning sorted relative paths of files only.
537
+ */
538
+ async function walkDirectory(root, options = {}) {
539
+ const ignores = /* @__PURE__ */ new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
540
+ const out = [];
541
+ async function walk(current) {
542
+ let entries;
543
+ try {
544
+ entries = await readdir(current);
545
+ } catch {
546
+ return;
547
+ }
548
+ for (const entry of entries) {
549
+ if (ignores.has(entry)) continue;
550
+ const abs = resolve(current, entry);
551
+ const stat = statSync(abs);
552
+ if (stat.isDirectory()) await walk(abs);
553
+ else if (stat.isFile()) out.push(relative(root, abs).split(sep).join("/"));
554
+ }
555
+ }
556
+ await walk(root);
557
+ out.sort();
558
+ return out;
559
+ }
560
+ /**
561
+ * Compare two directory trees file-by-file. Fixture file contents honor the
562
+ * unified `{{token}}` grammar (CONVENTIONS D4) — captures are recorded in
563
+ * `scope` in sorted-path order.
564
+ */
565
+ async function diffDirectories(expectedRoot, actualRoot, options = {}) {
566
+ const expectedFiles = await walkDirectory(expectedRoot, options);
567
+ const actualFiles = await walkDirectory(actualRoot, options);
568
+ const expectedSet = new Set(expectedFiles);
569
+ const actualSet = new Set(actualFiles);
570
+ const scope = options.scope ?? new CaptureScope();
571
+ const added = actualFiles.filter((f) => !expectedSet.has(f));
572
+ const removed = expectedFiles.filter((f) => !actualSet.has(f));
573
+ const changed = [];
574
+ for (const file of expectedFiles) {
575
+ if (!actualSet.has(file)) continue;
576
+ const expected = readFileSync(resolve(expectedRoot, file), "utf8");
577
+ const actual = readFileSync(resolve(actualRoot, file), "utf8");
578
+ if (!textEquals(expected, actual, scope)) changed.push({
579
+ actual,
580
+ expected,
581
+ path: file
582
+ });
583
+ }
584
+ return {
585
+ added,
586
+ changed,
587
+ removed
588
+ };
589
+ }
590
+ /**
591
+ * Read-only accessor for a directory produced by a spec action.
592
+ *
593
+ * Assertions go through `expect()` (async — they walk the disk):
594
+ * `await expect(result.directory('out')).toMatch('scaffold/out')`
595
+ * compares the tree against the fixture directory `expected/<name>/`.
596
+ */
597
+ var DirectoryAccessor = class {
598
+ /** @internal Ref-capture scope shared by the current spec execution. */
599
+ captures;
600
+ /** @internal Absolute path of the directory under assertion. */
601
+ root;
602
+ /** @internal Test-file directory — fixture resolution root for matchers. */
603
+ testDir;
604
+ constructor(absPath, testDir, captures) {
605
+ this.root = absPath;
606
+ this.testDir = testDir;
607
+ this.captures = captures ?? new CaptureScope();
608
+ }
609
+ /** List all files (recursively) under the directory, sorted. */
610
+ async files(options = {}) {
611
+ return walkDirectory(this.root, options);
612
+ }
613
+ };
614
+ //#endregion
615
+ //#region src/core/specification/shared/result/filesystem.ts
616
+ /**
617
+ * Read-only accessor for the whole temporary working directory used by a
618
+ * command spec.
619
+ *
620
+ * Assertions go through `expect()` (async — they walk the disk):
621
+ * `await expect(result.filesystem).toMatch('scaffolded')` compares the
622
+ * tree against the fixture directory `expected/<name>/`.
623
+ */
624
+ var FilesystemAccessor = class {
625
+ /** @internal Ref-capture scope shared by the current spec execution. */
626
+ captures;
627
+ /** The absolute path of the temporary working directory. */
628
+ cwd;
629
+ /** @internal Test-file directory — fixture resolution root for matchers. */
630
+ testDir;
631
+ constructor(cwd, testDir, captures) {
632
+ this.cwd = cwd;
633
+ this.testDir = testDir;
634
+ this.captures = captures ?? new CaptureScope();
635
+ }
636
+ /** List all files (recursively) under the working directory, sorted. */
637
+ async files(options = {}) {
638
+ return walkDirectory(this.cwd, options);
639
+ }
640
+ };
641
+ //#endregion
642
+ //#region src/core/specification/shared/caller.ts
643
+ /** Sibling module tests are callers, not internals (CONVENTIONS I2). */
644
+ const TEST_FILE = /\.test\.[cm]?[jt]s$/;
645
+ /** Where this very module sits inside the framework's SOURCE tree. */
646
+ const SOURCE_LOCATION = join("core", "specification", "shared");
647
+ /** The real path of `path`, or `path` itself when it cannot be resolved. */
648
+ function realPath(path) {
649
+ try {
650
+ return realpathSync(path);
651
+ } catch {
652
+ return path;
653
+ }
654
+ }
655
+ /**
656
+ * Directory of the running framework module itself. From the built package
657
+ * this is `<package root>/dist`, whatever the resolution path — including a
658
+ * `file:` link, where the real path carries neither `node_modules` nor
659
+ * `/src/…` and would otherwise be mistaken for a caller frame.
660
+ */
661
+ const FRAMEWORK_DIR = realPath(dirname(fileURLToPath(import.meta.url)));
662
+ /**
663
+ * The directory holding the framework's OWN modules: `<package>/dist` for a
664
+ * built install (everything is bundled there), `<repo>/src` when the framework
665
+ * runs from source — this module's known location inside that tree is what
666
+ * tells the two apart.
667
+ */
668
+ const FRAMEWORK_TREE = FRAMEWORK_DIR.endsWith(`${sep}${SOURCE_LOCATION}`) ? resolve(FRAMEWORK_DIR, "../../..") : FRAMEWORK_DIR;
669
+ /** Is `path` inside `directory` (strictly — a directory is not inside itself)? */
670
+ function isInside(directory, path) {
671
+ const rest = relative(directory, path);
672
+ return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
673
+ }
674
+ /**
675
+ * Is this stack frame a module of the framework itself, rather than of the code
676
+ * calling it?
677
+ *
678
+ * Answered by IDENTITY: the frame is inside the framework's own directory.
679
+ * Answering it by substring — `/src/core/`, `/src/integrations/`, `/src/vitest/`
680
+ * — was answering a different question, "does this path look like the framework's
681
+ * layout", and a consumer that happens to have its own `src/core/` matched it.
682
+ * Its files were then skipped as framework internals, and fixture resolution
683
+ * anchored on the wrong directory (or fell through to the cwd) with nothing
684
+ * reported.
685
+ *
686
+ * @internal
687
+ */
688
+ function isFrameworkFrame(filePath, frameworkTree = FRAMEWORK_TREE) {
689
+ if (TEST_FILE.test(filePath)) return false;
690
+ return isInside(realPath(frameworkTree), realPath(filePath));
691
+ }
692
+ /**
693
+ * Detect the directory of the first stack frame that lives outside this
694
+ * package. Used to anchor fixture resolution (`seeds/`, `requests/`,
695
+ * `expected/`, …) on the calling test / specification file.
696
+ *
697
+ * @internal
698
+ */
699
+ function getCallerDir() {
700
+ const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
701
+ if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
702
+ const lines = stack.split("\n");
703
+ for (const line of lines) {
704
+ const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?(?<filePath>[^:)]+):\d+:\d+/);
705
+ if (!match?.groups?.filePath) continue;
706
+ const filePath = match.groups.filePath;
707
+ if (filePath.includes("node_modules")) continue;
708
+ if (isFrameworkFrame(filePath)) continue;
709
+ return resolve(filePath, "..");
710
+ }
711
+ return process.cwd();
712
+ }
713
+ //#endregion
714
+ //#region src/core/specification/shared/result/grep.ts
715
+ /**
716
+ * Extract text blocks from output that contain a pattern.
717
+ * Splits by blank lines (how linter/compiler output is structured),
718
+ * returns only blocks matching the pattern.
719
+ *
720
+ * @example
721
+ * expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
722
+ * expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
723
+ */
724
+ function grep(output, pattern) {
725
+ return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
726
+ }
727
+ //#endregion
728
+ //#region src/core/specification/shared/result/text.ts
729
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
730
+ /** Strip ANSI escape sequences from a string. */
731
+ function stripAnsiCodes(value) {
732
+ return value.replace(ANSI_RE, "");
733
+ }
734
+ /**
735
+ * Read-only accessor for a captured text handle — THE universal one: stdout,
736
+ * stderr, container logs, and file text all surface as a `TextAccessor`.
737
+ *
738
+ * Backed by a primitive string, exposed via {@link text}, but also provides
739
+ * `toString()` / `valueOf()` for string coercion, so common patterns like
740
+ * `String(result.stdout)` and template-literal interpolation still work.
741
+ *
742
+ * ANSI escape sequences are stripped by default before every comparison
743
+ * (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
744
+ * an escape hatch for application noise not covered by the `{{token}}`
745
+ * grammar.
746
+ *
747
+ * Text operations are **closed** over the type: `.grep(pattern)` returns a
748
+ * `TextAccessor` (not a bare string), preserving the `expected/`-resolution
749
+ * context and the `transform`, so results are chainable
750
+ * (`result.stdout.grep(a).grep(b)`) and snapshot-able
751
+ * (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
752
+ *
753
+ * Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
754
+ * and `expect(result.stdout).toMatch('name.txt')` (resolved against
755
+ * `expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
756
+ */
757
+ var TextAccessor = class TextAccessor {
758
+ /** @internal Ref-capture scope shared by the current spec execution. */
759
+ captures;
760
+ /** @internal Stream label used in failure messages ("stdout" / "stderr"). */
761
+ streamName;
762
+ /** @internal Test-file directory — fixture resolution root for matchers. */
763
+ testDir;
764
+ /** The raw captured text (never transformed, ANSI preserved). */
765
+ text;
766
+ /** @internal Normaliser applied before comparisons, never to fixtures. */
767
+ transform;
768
+ constructor(value, streamName, testDir, options = {}) {
769
+ this.text = value;
770
+ this.streamName = streamName;
771
+ this.testDir = testDir;
772
+ this.transform = options.transform;
773
+ this.captures = options.captures ?? new CaptureScope();
774
+ }
775
+ /** @internal The text as compared by matchers — ANSI stripped, transform applied. */
776
+ get comparableText() {
777
+ const stripped = stripAnsiCodes(this.text);
778
+ return this.transform ? this.transform(stripped) : stripped;
779
+ }
780
+ /**
781
+ * Keep only the blank-line-separated blocks of the text that contain
782
+ * `pattern` (how linter/compiler output is structured), returned as a new
783
+ * `TextAccessor` — the same subject type, so the result is chainable and
784
+ * snapshot-able. The scalpel for probing large tool outputs; snapshot the
785
+ * whole surface by default, reach for `.grep()` for targeted checks.
786
+ */
787
+ grep(pattern) {
788
+ return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
789
+ captures: this.captures,
790
+ transform: this.transform
791
+ });
792
+ }
793
+ toString() {
794
+ return this.text;
795
+ }
796
+ valueOf() {
797
+ return this.text;
798
+ }
799
+ };
800
+ /**
801
+ * Wrap an arbitrary string into a {@link TextAccessor} anchored on the calling
802
+ * test's directory — the same caller-detection the builders use.
803
+ *
804
+ * The product surface of a test framework is its own error messages, checker
805
+ * output, and reports; those deserve the same goldening as any other output.
806
+ * `text()` makes an ad-hoc string a first-class snapshot subject:
807
+ *
808
+ * ```typescript
809
+ * const message = await catchMessage(() => expect(result.response).toMatch('wrong-body.http'));
810
+ * expect(text(message)).toMatch('wrong-body-error.txt'); // resolves to expected/
811
+ * ```
812
+ *
813
+ * ANSI is stripped before every comparison (the raw form stays on `.text`),
814
+ * the `{{token}}` grammar applies to the fixture, and `.grep()` composition
815
+ * works exactly as on any stream accessor.
816
+ */
817
+ function text(value) {
818
+ return new TextAccessor(value, "text", getCallerDir());
819
+ }
820
+ //#endregion
821
+ //#region src/core/specification/shared/result/json.ts
822
+ /**
823
+ * Read-only accessor for a JSON payload parsed from a text stream (stdout).
824
+ *
825
+ * Lazily parses the JSON on first use; parse errors are deferred until the
826
+ * value is read so that callers can still read the raw stream text without
827
+ * triggering a throw.
828
+ *
829
+ * ANSI escape sequences are stripped by default before `JSON.parse`
830
+ * (CONVENTIONS D6). When a `transform` is configured on the spec runner, it
831
+ * runs on the stripped text — an escape hatch for application noise not
832
+ * covered by the `{{token}}` grammar.
833
+ *
834
+ * Assertions go through `expect()`: `expect(result.json).toMatch('name.json')`
835
+ * (deep-equal against `expected/<name>`, with `{{placeholder}}` support).
836
+ */
837
+ var JsonAccessor = class {
838
+ /** @internal Ref-capture scope shared by the current spec execution. */
839
+ captures;
840
+ /** @internal */
841
+ rawText;
842
+ /** @internal Test-file directory — fixture resolution root for matchers. */
843
+ testDir;
844
+ /** @internal */
845
+ transform;
846
+ constructor(rawText, testDir, transform, captures) {
847
+ this.rawText = rawText;
848
+ this.testDir = testDir;
849
+ this.transform = transform;
850
+ this.captures = captures ?? new CaptureScope();
851
+ }
852
+ /** The parsed JSON value. Throws if the text is not valid JSON. */
853
+ get value() {
854
+ const stripped = stripAnsiCodes(this.rawText);
855
+ const source = this.transform ? this.transform(stripped) : stripped;
856
+ try {
857
+ return JSON.parse(source);
858
+ } catch {
859
+ const preview = source.slice(0, 200);
860
+ throw new Error(`stdout is not valid JSON: ${preview}`);
861
+ }
862
+ }
863
+ };
864
+ //#endregion
865
+ //#region src/core/specification/shared/result/table.ts
866
+ /**
867
+ * Read-only accessor for a database table after a specification run.
868
+ *
869
+ * Assertions go through `expect()` (async — they query the database):
870
+ *
871
+ * await expect(result.table('users', { database: 'db' })).toMatchRows({
872
+ * columns: ['name'],
873
+ * rows: [['Alice']],
874
+ * });
875
+ * await expect(result.table('users', { database: 'db' })).toBeEmpty();
876
+ */
877
+ var TableAccessor = class {
878
+ /** @internal Ref-capture scope shared by the current spec execution. */
879
+ captures;
880
+ /** @internal */
881
+ db;
882
+ /** The table name this accessor reads. */
883
+ name;
884
+ constructor(name, db, captures) {
885
+ this.name = name;
886
+ this.db = db;
887
+ this.captures = captures ?? new CaptureScope();
888
+ }
889
+ /** @internal Query the table — used by the toMatchRows / toBeEmpty matchers. */
890
+ query(columns) {
891
+ return this.db.query(this.name, columns);
892
+ }
893
+ };
894
+ //#endregion
895
+ //#region src/integrations/docker/docker-lookup.ts
896
+ /**
897
+ * Thin synchronous wrappers around the `docker` CLI used by the docker() spec
898
+ * mode. Sync is deliberate — the calls are fast, run only on the host, and
899
+ * keep the dispose path straightforward (`Symbol.asyncDispose` can still be
900
+ * async without needing these to be).
901
+ */
902
+ const DEFAULT_TIMEOUT = 1e4;
903
+ /** Return all container IDs (running or stopped) that carry `key=value`. */
904
+ function findContainersByLabel(key, value) {
905
+ try {
906
+ return execSync(`docker ps -aq -f label=${key}=${value}`, {
907
+ encoding: "utf8",
908
+ timeout: DEFAULT_TIMEOUT
909
+ }).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
910
+ } catch {
911
+ return [];
912
+ }
913
+ }
914
+ /** Return the raw `docker inspect` payload (object, not array) for a container. */
915
+ function inspectContainer(id) {
916
+ const raw = execSync(`docker inspect ${id}`, {
917
+ encoding: "utf8",
918
+ timeout: DEFAULT_TIMEOUT
919
+ });
920
+ const parsed = JSON.parse(raw);
921
+ return Array.isArray(parsed) ? parsed[0] : parsed;
922
+ }
923
+ /** Force-remove the given container IDs in a single call. Errors are swallowed. */
924
+ function removeContainers(ids) {
925
+ if (ids.length === 0) return;
926
+ try {
927
+ execSync(`docker rm -f ${ids.join(" ")}`, {
928
+ encoding: "utf8",
929
+ stdio: "pipe",
930
+ timeout: DEFAULT_TIMEOUT
931
+ });
932
+ } catch {}
933
+ }
934
+ //#endregion
935
+ //#region src/core/specification/shared/result/result.ts
936
+ /**
937
+ * Base result - common accessors available after any action type.
938
+ * Extended by HttpResult, CliResult, and used directly by job results.
939
+ *
940
+ * Every result carries a fresh {@link CaptureScope}: `match.ref()` captures
941
+ * and `{{type#ref}}` placeholders are scoped to one spec execution.
942
+ */
943
+ var BaseResult = class {
944
+ /** @internal Ref-capture scope shared by every assertion on this result. */
945
+ captures;
946
+ config;
947
+ testDir;
948
+ workDir;
949
+ constructor(options) {
950
+ this.config = options.config;
951
+ this.testDir = options.testDir;
952
+ this.workDir = options.workDir;
953
+ this.captures = new CaptureScope(options.workDir ? safeRealpath(options.workDir) : void 0);
954
+ }
955
+ /** Access a directory (relative to the working directory) for snapshot assertions. */
956
+ directory(path = ".") {
957
+ return new DirectoryAccessor(resolve(this.workDir ?? this.testDir, path), this.testDir, this.captures);
958
+ }
959
+ /** Access a single file (relative to the working directory) for content assertions. */
960
+ file(path) {
961
+ const resolvedPath = resolve(this.workDir ?? this.testDir, path);
962
+ const exists = existsSync(resolvedPath);
963
+ const { captures, testDir } = this;
964
+ return {
965
+ get content() {
966
+ if (!exists) throw new Error(`File not found: ${path}`);
967
+ return readFileSync(resolvedPath, "utf8");
968
+ },
969
+ exists,
970
+ grep(pattern) {
971
+ if (!exists) throw new Error(`File not found: ${path}`);
972
+ return new TextAccessor(readFileSync(resolvedPath, "utf8"), path, testDir, { captures }).grep(pattern);
973
+ }
974
+ };
975
+ }
976
+ /** Access a database table for row-level assertions via expect() matchers. */
977
+ table(tableName, options) {
978
+ validateDatabaseOption("table", this.config, options?.database);
979
+ const db = this.resolveDatabase(options?.database);
980
+ if (!db) throw new Error(options?.database ? `table("${tableName}") requires database "${options.database}" but it was not found` : `table("${tableName}") requires a database adapter`);
981
+ return new TableAccessor(tableName, db, this.captures);
982
+ }
983
+ resolveDatabase(databaseKey) {
984
+ if (databaseKey && this.config.databases) return this.config.databases.get(databaseKey);
985
+ return this.config.database;
986
+ }
987
+ };
988
+ /**
989
+ * The realpath form of a working directory — what a child process prints as
990
+ * `$PWD` (macOS resolves the tmpdir symlink), and therefore what `{{workdir}}`
991
+ * compares against. Falls back to the path itself when it cannot be resolved.
992
+ *
993
+ * @internal Shared with the literate runner, which builds its own file-wide scope.
994
+ */
995
+ function safeRealpath(path) {
996
+ try {
997
+ return realpathSync(path);
998
+ } catch {
999
+ return path;
1000
+ }
1001
+ }
1002
+ /**
1003
+ * Enforce CONVENTIONS A7: with two or more databases in the services record
1004
+ * the `database` option is mandatory on `.seed()` / `.table()`; with exactly
1005
+ * one it is forbidden (redundant).
1006
+ *
1007
+ * @internal
1008
+ */
1009
+ function validateDatabaseOption(method, config, database) {
1010
+ const keys = config.databaseKeys;
1011
+ if (!keys) return;
1012
+ if (keys.length >= 2 && !database) throw new Error(`${method}(): ${keys.length} databases are declared (${keys.map((k) => `"${k}"`).join(", ")}) — pass { database: <key> } to target one of them.`);
1013
+ if (keys.length === 1 && database) throw new Error(`${method}(): redundant database option — "${keys[0]}" is the only declared database.`);
1014
+ }
1015
+ //#endregion
1016
+ //#region src/core/specification/cli/result.ts
1017
+ /**
1018
+ * Result from a command action (`.exec()`).
1019
+ *
1020
+ * When the runner was configured with a `docker` option, the result also
1021
+ * exposes container accessors and participates in `Symbol.asyncDispose`
1022
+ * cleanup. The Docker shell-outs are **lazy**: a test that never calls
1023
+ * `.container(...)` never queries the Docker daemon, so command-only tests
1024
+ * pay zero Docker cost even when the runner is Docker-aware.
1025
+ *
1026
+ * Dispose always runs one final label-filtered `docker ps` to catch
1027
+ * containers that were spawned during `.exec` but never asserted on —
1028
+ * tests still get leak-free cleanup even if they forget to reach into
1029
+ * a container.
1030
+ */
1031
+ var CliResult = class extends BaseResult {
1032
+ commandOutput;
1033
+ containersCache = null;
1034
+ dockerConfig;
1035
+ testRunId;
1036
+ transform;
1037
+ constructor(options) {
1038
+ super(options);
1039
+ this.commandOutput = options.commandOutput;
1040
+ this.dockerConfig = options.dockerConfig;
1041
+ this.testRunId = options.testRunId;
1042
+ this.transform = options.transform;
1043
+ }
1044
+ /** The process exit code. */
1045
+ get exitCode() {
1046
+ return this.commandOutput.exitCode;
1047
+ }
1048
+ /** Accessor for the captured standard output with file-based assertions. */
1049
+ get stdout() {
1050
+ return new TextAccessor(this.commandOutput.stdout, "stdout", this.testDir, {
1051
+ captures: this.captures,
1052
+ transform: this.transform
1053
+ });
1054
+ }
1055
+ /** Accessor for the captured standard error with file-based assertions. */
1056
+ get stderr() {
1057
+ return new TextAccessor(this.commandOutput.stderr, "stderr", this.testDir, {
1058
+ captures: this.captures,
1059
+ transform: this.transform
1060
+ });
1061
+ }
1062
+ /** Accessor for parsing stdout as JSON and asserting against JSON fixtures. */
1063
+ get json() {
1064
+ return new JsonAccessor(this.commandOutput.stdout, this.testDir, this.transform, this.captures);
1065
+ }
1066
+ /** Accessor for the temporary working directory the command ran in. */
1067
+ get filesystem() {
1068
+ if (!this.workDir) throw new Error("CliResult.filesystem requires a working directory");
1069
+ return new FilesystemAccessor(this.workDir, this.testDir, this.captures);
1070
+ }
1071
+ /**
1072
+ * Look up a container the command spawned during this run by the value of
1073
+ * its name-label (as declared in the `docker.nameLabel` of the
1074
+ * `specification.cli()` options).
1075
+ * First access triggers a one-shot `docker ps` + `docker inspect`
1076
+ * query and caches the result for the rest of the result's lifetime.
1077
+ * Tests that don't call this never touch Docker.
1078
+ *
1079
+ * Returns an accessor with `.exists === false` when no container
1080
+ * carries that name — callers can still assert absence without a try.
1081
+ */
1082
+ container(name) {
1083
+ this.ensureDockerAware("container");
1084
+ this.loadContainers();
1085
+ const captured = this.containersCache.get(name);
1086
+ if (!captured) return new ContainerAccessor(null, null, this.testDir, this.transform);
1087
+ return new ContainerAccessor(captured.id, captured.inspect, this.testDir, this.transform);
1088
+ }
1089
+ /** All captured container IDs. Triggers the same lazy query as `container()`. */
1090
+ get containerIds() {
1091
+ this.ensureDockerAware("containerIds");
1092
+ this.loadContainers();
1093
+ return [...this.containersCache.values()].map((c) => c.id);
1094
+ }
1095
+ async [Symbol.asyncDispose]() {
1096
+ if (!this.dockerConfig || !this.testRunId) return;
1097
+ removeContainers(this.containersCache !== null ? [...this.containersCache.values()].map((c) => c.id) : findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId));
1098
+ }
1099
+ ensureDockerAware(member) {
1100
+ if (!this.dockerConfig || !this.testRunId) throw new Error(`CliResult.${member}: runner was not configured with a docker option. Pass \`docker: { envVar, nameLabel, testRunLabel }\` in the specification.cli() options.`);
1101
+ }
1102
+ loadContainers() {
1103
+ if (this.containersCache !== null) return;
1104
+ const ids = findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId);
1105
+ const map = /* @__PURE__ */ new Map();
1106
+ for (const id of ids) {
1107
+ let inspect;
1108
+ try {
1109
+ inspect = inspectContainer(id);
1110
+ } catch {
1111
+ continue;
1112
+ }
1113
+ const nameValue = (inspect?.Config?.Labels ?? inspect?.config?.labels ?? {})[this.dockerConfig.nameLabel];
1114
+ const key = typeof nameValue === "string" && nameValue.length > 0 ? nameValue : id;
1115
+ map.set(key, {
1116
+ id,
1117
+ inspect
1118
+ });
1119
+ }
1120
+ this.containersCache = map;
1121
+ }
1122
+ };
1123
+ //#endregion
1124
+ //#region src/integrations/docker/container-accessor.ts
1125
+ const EXEC_TIMEOUT = 1e4;
1126
+ function readInspectState(inspect) {
1127
+ const state = inspect?.State ?? inspect?.state;
1128
+ if (!state) return {
1129
+ running: false,
1130
+ status: "unknown"
1131
+ };
1132
+ return {
1133
+ running: Boolean(state.Running ?? state.running ?? false),
1134
+ status: String(state.Status ?? state.status ?? "unknown")
1135
+ };
1136
+ }
1137
+ /**
1138
+ * Assertion accessor for a single Docker container captured by the docker()
1139
+ * spec mode. Mirrors the shape of {@link CliResult} so tests use the same
1140
+ * vocabulary (`stdout.toContain`, `file(...).content`, etc.) regardless
1141
+ * of where output came from.
1142
+ *
1143
+ * Sync state (`exists`, `running`, `status`) is derived from the `docker
1144
+ * inspect` payload captured at result-construction time. Logs are fetched
1145
+ * lazily on first access to `stdout`/`stderr`.
1146
+ */
1147
+ var ContainerAccessor = class {
1148
+ cachedLogs = null;
1149
+ containerId;
1150
+ exists;
1151
+ inspectData;
1152
+ running;
1153
+ status;
1154
+ testDir;
1155
+ transform;
1156
+ /**
1157
+ * Underlying Docker container ID, or `null` if no container was captured
1158
+ * for this name. Useful when a follow-up CLI command needs to reference
1159
+ * the container by id (e.g. `spwn world inspect <id>`). Prefer the other
1160
+ * accessors for common reads — pulling the raw id is the escape hatch.
1161
+ */
1162
+ get id() {
1163
+ return this.containerId;
1164
+ }
1165
+ constructor(containerId, inspectData, testDir, transform) {
1166
+ this.containerId = containerId;
1167
+ this.inspectData = inspectData;
1168
+ this.testDir = testDir;
1169
+ this.transform = transform;
1170
+ this.exists = containerId !== null;
1171
+ if (this.exists) {
1172
+ const state = readInspectState(inspectData);
1173
+ this.running = state.running;
1174
+ this.status = state.status;
1175
+ } else {
1176
+ this.running = false;
1177
+ this.status = "absent";
1178
+ }
1179
+ }
1180
+ /**
1181
+ * Raw `docker inspect` object for this container. Throws if the container
1182
+ * was not captured (i.e. `.exists === false`).
1183
+ */
1184
+ get inspect() {
1185
+ this.requireExists("inspect");
1186
+ return new JsonAccessor(JSON.stringify(this.inspectData), this.testDir, this.transform);
1187
+ }
1188
+ /** Captured logs (stdout+stderr combined for v1). */
1189
+ get stdout() {
1190
+ this.requireExists("stdout");
1191
+ return new TextAccessor(this.loadLogs(), "stdout", this.testDir, { transform: this.transform });
1192
+ }
1193
+ /** Captured logs (stdout+stderr combined for v1). */
1194
+ get stderr() {
1195
+ this.requireExists("stderr");
1196
+ return new TextAccessor(this.loadLogs(), "stderr", this.testDir, { transform: this.transform });
1197
+ }
1198
+ /**
1199
+ * Read a file from inside the container via `docker exec cat`. The
1200
+ * returned object satisfies the same {@link FileAccessor} shape used by
1201
+ * the host filesystem accessor, so tests do not need to learn a new API.
1202
+ */
1203
+ file(path) {
1204
+ this.requireExists("file");
1205
+ const id = this.containerId;
1206
+ const exists = this.containerExec(id, [
1207
+ "test",
1208
+ "-e",
1209
+ path
1210
+ ]).exitCode === 0;
1211
+ const { testDir } = this;
1212
+ const readContent = () => {
1213
+ if (!exists) throw new Error(`File not found in container: ${path}`);
1214
+ return execSync(`docker exec ${id} cat ${JSON.stringify(path)}`, {
1215
+ encoding: "utf8",
1216
+ timeout: EXEC_TIMEOUT
1217
+ });
1218
+ };
1219
+ return {
1220
+ get content() {
1221
+ return readContent();
1222
+ },
1223
+ exists,
1224
+ grep(pattern) {
1225
+ return new TextAccessor(readContent(), path, testDir).grep(pattern);
1226
+ }
1227
+ };
1228
+ }
1229
+ /**
1230
+ * Run a shell command inside the container and get back the same
1231
+ * {@link CliResult} used for host-side executions.
1232
+ */
1233
+ async exec(cmd) {
1234
+ this.requireExists("exec");
1235
+ return new CliResult({
1236
+ commandOutput: this.containerExec(this.containerId, [
1237
+ "sh",
1238
+ "-c",
1239
+ cmd
1240
+ ]),
1241
+ config: {},
1242
+ testDir: this.testDir,
1243
+ transform: this.transform,
1244
+ workDir: this.testDir
1245
+ });
1246
+ }
1247
+ requireExists(member) {
1248
+ if (!this.exists) throw new Error(`ContainerAccessor.${member}: container does not exist (check .exists first)`);
1249
+ }
1250
+ loadLogs() {
1251
+ if (this.cachedLogs !== null) return this.cachedLogs;
1252
+ try {
1253
+ const out = execSync(`docker logs ${this.containerId}`, {
1254
+ encoding: "utf8",
1255
+ stdio: [
1256
+ "ignore",
1257
+ "pipe",
1258
+ "pipe"
1259
+ ],
1260
+ timeout: EXEC_TIMEOUT
1261
+ });
1262
+ this.cachedLogs = out;
1263
+ } catch (error) {
1264
+ this.cachedLogs = typeof error?.stdout === "string" ? error.stdout : String(error?.stderr ?? "");
1265
+ }
1266
+ return this.cachedLogs ?? "";
1267
+ }
1268
+ containerExec(id, argv) {
1269
+ try {
1270
+ return {
1271
+ exitCode: 0,
1272
+ stderr: "",
1273
+ stdout: execSync(`docker exec ${id} ${argv.map((a) => JSON.stringify(a)).join(" ")}`, {
1274
+ encoding: "utf8",
1275
+ stdio: [
1276
+ "ignore",
1277
+ "pipe",
1278
+ "pipe"
1279
+ ],
1280
+ timeout: EXEC_TIMEOUT
1281
+ })
1282
+ };
1283
+ } catch (error) {
1284
+ return {
1285
+ exitCode: typeof error?.status === "number" ? error.status : 1,
1286
+ stderr: typeof error?.stderr === "string" ? error.stderr : String(error?.message ?? ""),
1287
+ stdout: typeof error?.stdout === "string" ? error.stdout : ""
1288
+ };
1289
+ }
1290
+ }
1291
+ };
1292
+ //#endregion
1293
+ //#region src/vitest/update.ts
1294
+ /**
1295
+ * Snapshot update-mode detection. `TEST_UPDATE=1` (framework convention, see
1296
+ * CONVENTIONS E1) or vitest's `-u` / `--update` flag turn fixture mismatches
1297
+ * into fixture rewrites.
1298
+ */
1299
+ function shouldUpdateSnapshots() {
1300
+ if (process.env.TEST_UPDATE === "1") return true;
1301
+ return process.argv.includes("-u") || process.argv.includes("--update");
1302
+ }
1303
+ /** Standard hint appended to missing-fixture errors. */
1304
+ const UPDATE_HINT = "Run with TEST_UPDATE=1 (or vitest -u) to create it.";
1305
+ //#endregion
1306
+ //#region src/core/specification/website/serve.adapter.ts
1307
+ /** Grace period between SIGTERM and the SIGKILL escalation. */
1308
+ const KILL_GRACE_MS = 2e3;
1309
+ const DEFAULT_READY_TIMEOUT = 3e4;
1310
+ const READY_POLL_INTERVAL_MS = 250;
1311
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1312
+ /** Ask the OS for a free TCP port. */
1313
+ function findFreePort() {
1314
+ return new Promise((resolve, reject) => {
1315
+ const server = createServer();
1316
+ server.listen(0, () => {
1317
+ const address = server.address();
1318
+ if (address === null || typeof address === "string") {
1319
+ server.close();
1320
+ reject(/* @__PURE__ */ new Error("could not determine a free port"));
1321
+ return;
1322
+ }
1323
+ const { port } = address;
1324
+ server.close(() => resolve(port));
1325
+ });
1326
+ server.on("error", reject);
1327
+ });
1328
+ }
1329
+ /**
1330
+ * Starts the site under test as a child process and waits until it answers
1331
+ * HTTP. Any HTTP status counts as ready — a 404 on the ready path is still a
1332
+ * listening server; only connection failures keep the poll going.
1333
+ *
1334
+ * The child runs through the shell in its own process group (POSIX) so
1335
+ * `stop()` can terminate the whole tree — a `next start` grandchild must not
1336
+ * outlive the spec run (same escalation as the cli exec adapter: SIGTERM,
1337
+ * then SIGKILL after a 2 s grace).
1338
+ */
1339
+ var ServeAdapter = class {
1340
+ /** The port the server ended up on — known only after {@link start}. */
1341
+ port = null;
1342
+ child = null;
1343
+ /** Extra environment injected into the child (e.g. the stub backend URL). */
1344
+ extraEnv;
1345
+ /** The constructor named in error messages — `website`, or `mobile` (appium server). */
1346
+ facet;
1347
+ options;
1348
+ output = "";
1349
+ root;
1350
+ constructor(options, root, facet = "website", extraEnv = {}) {
1351
+ this.extraEnv = extraEnv;
1352
+ this.facet = facet;
1353
+ this.options = options;
1354
+ this.root = root;
1355
+ }
1356
+ /** Start the server and resolve with its base URL once it is ready. */
1357
+ async start() {
1358
+ const banner = this.options.ready instanceof RegExp ? this.options.ready : null;
1359
+ const port = banner ? null : this.options.port ?? await findFreePort();
1360
+ const timeout = this.options.timeout ?? DEFAULT_READY_TIMEOUT;
1361
+ this.child = spawn(this.options.command, [], {
1362
+ cwd: this.root,
1363
+ detached: process.platform !== "win32",
1364
+ env: {
1365
+ ...process.env,
1366
+ ...this.extraEnv,
1367
+ ...port === null ? {} : { PORT: String(port) }
1368
+ },
1369
+ shell: true,
1370
+ stdio: [
1371
+ "ignore",
1372
+ "pipe",
1373
+ "pipe"
1374
+ ]
1375
+ });
1376
+ this.child.stdout?.on("data", (data) => {
1377
+ this.output += data.toString();
1378
+ });
1379
+ this.child.stderr?.on("data", (data) => {
1380
+ this.output += data.toString();
1381
+ });
1382
+ let exited = false;
1383
+ this.child.on("exit", () => {
1384
+ exited = true;
1385
+ });
1386
+ const deadline = Date.now() + timeout;
1387
+ const died = () => {
1388
+ throw new Error(`specification.${this.facet}(): server command exited before it was ready.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
1389
+ };
1390
+ if (banner) for (;;) {
1391
+ banner.lastIndex = 0;
1392
+ const found = banner.exec(this.output);
1393
+ if (found) {
1394
+ const captured = Number(found[1]);
1395
+ if (!Number.isInteger(captured)) {
1396
+ await this.stop();
1397
+ throw new Error(`specification.${this.facet}(): the ready pattern ${String(banner)} matched but its capture group is not a port ("${found[1]}").\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
1398
+ }
1399
+ this.port = captured;
1400
+ return `http://127.0.0.1:${captured}`;
1401
+ }
1402
+ if (exited) died();
1403
+ if (Date.now() >= deadline) {
1404
+ await this.stop();
1405
+ throw new Error(`specification.${this.facet}(): server did not print ${String(banner)} within ${timeout}ms.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
1406
+ }
1407
+ await delay(READY_POLL_INTERVAL_MS);
1408
+ }
1409
+ const baseUrl = `http://127.0.0.1:${port}`;
1410
+ const readyUrl = `${baseUrl}${this.options.ready ?? "/"}`;
1411
+ for (;;) {
1412
+ if (exited) died();
1413
+ try {
1414
+ await fetch(readyUrl, { redirect: "manual" });
1415
+ this.port = port;
1416
+ return baseUrl;
1417
+ } catch {
1418
+ if (Date.now() >= deadline) {
1419
+ await this.stop();
1420
+ throw new Error(`specification.${this.facet}(): server did not answer on ${readyUrl} within ${timeout}ms.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
1421
+ }
1422
+ await delay(READY_POLL_INTERVAL_MS);
1423
+ }
1424
+ }
1425
+ }
1426
+ /** Terminate the server process group (idempotent). */
1427
+ async stop() {
1428
+ const child = this.child;
1429
+ if (!child) return;
1430
+ this.child = null;
1431
+ await new Promise((resolve) => {
1432
+ let exited = false;
1433
+ child.on("exit", () => {
1434
+ exited = true;
1435
+ resolve();
1436
+ });
1437
+ if (child.exitCode !== null || child.signalCode !== null) {
1438
+ resolve();
1439
+ return;
1440
+ }
1441
+ this.kill(child, "SIGTERM");
1442
+ setTimeout(() => {
1443
+ if (!exited) {
1444
+ this.kill(child, "SIGKILL");
1445
+ resolve();
1446
+ }
1447
+ }, KILL_GRACE_MS).unref?.();
1448
+ });
1449
+ }
1450
+ /** Group-aware kill: signal the process group, fall back to the child. */
1451
+ kill(child, signal) {
1452
+ if (child.pid !== void 0 && process.platform !== "win32") try {
1453
+ process.kill(-child.pid, signal);
1454
+ return;
1455
+ } catch {}
1456
+ child.kill(signal);
1457
+ }
1458
+ };
1459
+ //#endregion
1460
+ //#region src/core/specification/cli/literate.ts
1461
+ /**
1462
+ * The form a stream is compared in: ANSI stripped (rule D6) and the runner's
1463
+ * `transform` applied. Nothing else — a block scalar states its own final
1464
+ * newline (`|` keeps it, `|-` drops it), so the comparison is byte-exact and
1465
+ * has no normalisation to hide behind.
1466
+ */
1467
+ function comparable(raw, transform) {
1468
+ const stripped = stripAnsiCodes(raw);
1469
+ return transform ? transform(stripped) : stripped;
1470
+ }
1471
+ /**
1472
+ * Line equality AS THE COMPARISON JUDGED IT — a `{{url}}` line that matched is
1473
+ * equal, whatever port the run picked. Each line is judged on its own fresh
1474
+ * scope: this is a rendering, and a ref captured by an earlier line must not
1475
+ * decide how a later one is displayed.
1476
+ */
1477
+ function tokenAwareEquals(scope) {
1478
+ return (expected, actual) => textEquals(expected, actual, new CaptureScope(scope.workdir));
1479
+ }
1480
+ /** A path under the working directory, refusing anything that would escape it. */
1481
+ function resolveUnderWorkDir(path, workDir, context) {
1482
+ const target = resolve(workDir, path);
1483
+ const inside = relative(workDir, target);
1484
+ if (isAbsolute(path) || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`${context.displayPath}: files: "${path}" leaves the working directory — a files: key is a relative path under the cwd`);
1485
+ return target;
1486
+ }
1487
+ /** A one-line preview of a file's content for the failure rendering. */
1488
+ function preview(text) {
1489
+ return JSON.stringify(text.length > 160 ? `${text.slice(0, 160)}…` : text);
1490
+ }
1491
+ function readIfPresent(path) {
1492
+ try {
1493
+ return statSync(path).isFile() ? readFileSync(path, "utf8") : null;
1494
+ } catch {
1495
+ return null;
1496
+ }
1497
+ }
1498
+ /** Check one `files:` entry and render its failure, or `null` when it holds. */
1499
+ function checkFile(assertion, workDir, context, scope) {
1500
+ const target = resolveUnderWorkDir(assertion.path, workDir, context);
1501
+ let exists;
1502
+ try {
1503
+ exists = statSync(target) !== void 0;
1504
+ } catch {
1505
+ exists = false;
1506
+ }
1507
+ if (assertion.kind === "absent") return exists ? `${assertion.path}: expected absent, got a file` : null;
1508
+ if (!exists) return `${assertion.path}: expected to exist, got nothing at that path`;
1509
+ if (assertion.kind === "exists") return null;
1510
+ const content = readIfPresent(target);
1511
+ if (content === null) return `${assertion.path}: expected a readable file, got a directory`;
1512
+ for (const needle of assertion.contains) if (!textContains(needle.text, content, scope)) return `${assertion.path}: expected to contain ${preview(needle.text)}, got ${preview(content)}`;
1513
+ if (assertion.equals !== null && !textEquals(assertion.equals.text, content, scope)) return `${assertion.path}: expected ${preview(assertion.equals.text)}, got ${preview(content)}`;
1514
+ return null;
1515
+ }
1516
+ /**
1517
+ * The failure a mismatching run throws: the description, the command that ran,
1518
+ * every detail the comparison rejected, and where to open the document. The
1519
+ * update hint names exactly what a rewrite touches, so it cannot be read as
1520
+ * "this will re-generate my `files:`".
1521
+ *
1522
+ * The stack is REPLACED by the run's own line. Left alone it would carry the
1523
+ * engine's frames and end inside the module the plugin generates — pointing the
1524
+ * reader at a line of a file they never wrote. One frame, on the `command:` key,
1525
+ * is the whole truth here.
1526
+ */
1527
+ function failure(document, run, context, details) {
1528
+ return atLine(new Error([
1529
+ "Spec mismatch",
1530
+ "",
1531
+ document.description,
1532
+ "",
1533
+ `$ ${run.command}`,
1534
+ "",
1535
+ details.join("\n\n"),
1536
+ "",
1537
+ `${context.displayPath}:${run.commandLine}`,
1538
+ "Run with TEST_UPDATE=1 to rewrite the runs — the exit code and the streams, and nothing else."
1539
+ ].join("\n")), context.filePath, run.commandLine);
1540
+ }
1541
+ /**
1542
+ * Point an error's stack at one frame: the line of the `.spec.yaml` that owns
1543
+ * the failure. The framework's own frames carry nothing a reader of a spec
1544
+ * document can act on, and the generated module's frame actively misleads.
1545
+ */
1546
+ function atLine(error, filePath, line) {
1547
+ error.stack = `${error.name}: ${error.message}\n at ${filePath}:${line}:1`;
1548
+ return error;
1549
+ }
1550
+ /** Compare one run against what the command actually did. */
1551
+ function assertRun(document, outcome, context, workDir, scope) {
1552
+ const { actual, run } = outcome;
1553
+ if (actual.exitCode !== run.exitCode) throw failure(document, run, context, [[
1554
+ "exit code mismatch",
1555
+ ` expected: ${run.exitCode}`,
1556
+ ` received: ${actual.exitCode}`,
1557
+ ...outcome.stderr.length > 0 ? [
1558
+ "",
1559
+ "stderr was:",
1560
+ outcome.stderr.replace(/\n$/, "")
1561
+ ] : []
1562
+ ].join("\n")]);
1563
+ const equals = tokenAwareEquals(scope);
1564
+ const details = [];
1565
+ for (const [name, expected, received] of [[
1566
+ "stdout",
1567
+ run.stdout?.text ?? "",
1568
+ outcome.stdout
1569
+ ], [
1570
+ "stderr",
1571
+ run.stderr?.text ?? "",
1572
+ outcome.stderr
1573
+ ]]) if (!textEquals(expected, received, scope)) details.push(formatStdoutDiff(name, expected, received, { equals }));
1574
+ const fileFailures = run.files.map((assertion) => checkFile(assertion, workDir, context, scope)).filter((line) => line !== null);
1575
+ if (fileFailures.length > 0) details.push([
1576
+ "files mismatch",
1577
+ "",
1578
+ ...fileFailures.map((line) => ` ${line}`)
1579
+ ].join("\n"));
1580
+ if (details.length > 0) throw failure(document, run, context, details);
1581
+ }
1582
+ /**
1583
+ * The rewritten runs: exit codes and streams from the run, placeholders of the
1584
+ * previous golden preserved by pattern (rule D5). A stream that ends up empty
1585
+ * loses its key, so a rewritten document passes the next run.
1586
+ */
1587
+ function updates(outcomes, scope) {
1588
+ return outcomes.map(({ actual, run, stderr, stdout }) => ({
1589
+ exitCode: actual.exitCode,
1590
+ stderr: mergeTextPreservingPlaceholders(run.stderr?.text ?? null, stderr, scope),
1591
+ stdout: mergeTextPreservingPlaceholders(run.stdout?.text ?? null, stdout, scope)
1592
+ }));
1593
+ }
1594
+ /**
1595
+ * Start every server the document names, in declaration order, before the first
1596
+ * run. They all stay live for the whole file and are stopped together — one
1597
+ * document is one scenario, and its servers are part of its ground.
1598
+ */
1599
+ async function startServers(document, config, displayPath) {
1600
+ const registry = config.serveRegistry ?? {};
1601
+ const started = [];
1602
+ const env = {};
1603
+ const stop = async () => {
1604
+ for (const adapter of started.toReversed()) await adapter.stop();
1605
+ };
1606
+ for (const entry of document.serve) {
1607
+ const registration = registry[entry.name];
1608
+ if (!registration) {
1609
+ await stop();
1610
+ const known = Object.keys(registry);
1611
+ throw new Error(`${displayPath}:${entry.line}: serve: "${entry.name}" is not registered — ${known.length === 0 ? "specification.cli() declares no `serve` option." : `registered servers: ${known.join(", ")}.`}`);
1612
+ }
1613
+ const adapter = new ServeAdapter({
1614
+ command: registration.command,
1615
+ ready: registration.ready
1616
+ }, config.root ?? process.cwd(), "cli", entry.env);
1617
+ started.push(adapter);
1618
+ try {
1619
+ await adapter.start();
1620
+ } catch (error) {
1621
+ await stop();
1622
+ throw error;
1623
+ }
1624
+ env[registration.env] = registration.url(adapter.port);
1625
+ }
1626
+ return {
1627
+ env,
1628
+ stop
1629
+ };
1630
+ }
1631
+ /** Resolve the document's `env:` entries against the registered sets. `$WORKDIR` expands. */
1632
+ function documentEnv(document, config, displayPath) {
1633
+ const sets = config.envSets ?? {};
1634
+ const env = {};
1635
+ for (const token of document.env) {
1636
+ if (token.kind === "set") {
1637
+ const declared = sets[token.name];
1638
+ if (!declared) {
1639
+ const known = Object.keys(sets);
1640
+ throw new Error(`${displayPath}:${token.line}: env: "${token.name}" is not a registered env set — ${known.length === 0 ? "specification.cli() declares no `env` option." : `registered sets: ${known.join(", ")}.`}`);
1641
+ }
1642
+ Object.assign(env, declared);
1643
+ continue;
1644
+ }
1645
+ env[token.key] = token.value;
1646
+ }
1647
+ return env;
1648
+ }
1649
+ function expandWorkdir(env, workDir) {
1650
+ const resolved = {};
1651
+ for (const [key, value] of Object.entries(env)) resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
1652
+ return resolved;
1653
+ }
1654
+ /**
1655
+ * Run one spec document end to end and resolve with the LAST run's result —
1656
+ * the handle a `.test.ts` adds its own assertion to (a directory golden, a
1657
+ * grep) after `cli.run('<case>.spec.yaml')`.
1658
+ *
1659
+ * Under `TEST_UPDATE=1` nothing is asserted: every run executes, and each one's
1660
+ * exit code and streams are rewritten from the actual output with the previous
1661
+ * golden's placeholders preserved.
1662
+ */
1663
+ async function runSpecDocument(options) {
1664
+ const { config, displayPath, filePath, testDir, workDir } = options;
1665
+ if (!config.command) throw new Error(".run(): spec documents require a command adapter (use specification.cli())");
1666
+ let file;
1667
+ try {
1668
+ file = readSpecFile(readFileSync(filePath, "utf8"), displayPath);
1669
+ } catch (error) {
1670
+ throw error instanceof SpecSyntaxError ? atLine(error, filePath, error.line) : error;
1671
+ }
1672
+ const document = file.document;
1673
+ const fileDir = filePath.replace(/[/\\][^/\\]*$/, "");
1674
+ for (const fixture of document.fixtures) {
1675
+ const { dest, src } = copyPlan(fixture.path, fileDir, workDir);
1676
+ cpSync(src, dest, { recursive: true });
1677
+ }
1678
+ const servers = await startServers(document, config, displayPath);
1679
+ const scope = new CaptureScope(safeRealpath(workDir));
1680
+ const env = expandWorkdir({
1681
+ ...options.baseEnv,
1682
+ ...servers.env,
1683
+ ...documentEnv(document, config, displayPath)
1684
+ }, workDir);
1685
+ let outcomes;
1686
+ try {
1687
+ outcomes = [];
1688
+ for (const run of document.runs) {
1689
+ const actual = run.waitFor === null ? await config.command.exec(run.command, workDir, env, {
1690
+ stdin: run.stdin ?? void 0,
1691
+ timeout: run.timeout ?? void 0
1692
+ }) : await config.command.watch(run.command, workDir, {
1693
+ timeout: run.timeout ?? void 0,
1694
+ waitFor: run.waitFor
1695
+ }, env);
1696
+ outcomes.push({
1697
+ actual,
1698
+ run,
1699
+ stderr: comparable(actual.stderr, config.transform),
1700
+ stdout: comparable(actual.stdout, config.transform)
1701
+ });
1702
+ }
1703
+ } finally {
1704
+ await servers.stop();
1705
+ }
1706
+ if (shouldUpdateSnapshots() && options.frozen !== true) writeFileSync(filePath, updateSpecFile(file, updates(outcomes, scope)));
1707
+ else for (const outcome of outcomes) assertRun(document, outcome, {
1708
+ displayPath,
1709
+ filePath
1710
+ }, workDir, scope);
1711
+ return new CliResult({
1712
+ commandOutput: outcomes.at(-1).actual,
1713
+ config,
1714
+ dockerConfig: config.dockerConfig,
1715
+ testDir,
1716
+ testRunId: config.dockerTestRunId,
1717
+ transform: config.transform,
1718
+ workDir
1719
+ });
1720
+ }
1721
+ /** The title a transformed `.spec.yaml` module runs under — the `description:` line. */
1722
+ function specDescription(content, displayPath) {
1723
+ return readSpecFile(content, displayPath).document.description;
1724
+ }
1725
+ //#endregion
1726
+ export { placeholderPatternSource as A, formatResponseDiff as C, hasPlaceholders as D, formatTableDiff as E, structuralEquals as M, structuralSubset as N, mergePreservingPlaceholders as O, textEquals as P, formatDirectoryDiff as S, formatStdoutDiff as T, getCallerDir as _, shouldUpdateSnapshots as a, diffDirectories as b, BaseResult as c, inspectContainer as d, removeContainers as f, text as g, TextAccessor as h, UPDATE_HINT as i, renderExpected as j, mergeTextPreservingPlaceholders as k, validateDatabaseOption as l, JsonAccessor as m, specDescription as n, ContainerAccessor as o, TableAccessor as p, ServeAdapter as r, CliResult as s, runSpecDocument as t, findContainersByLabel as u, FilesystemAccessor as v, formatStartupReport as w, walkDirectory as x, DirectoryAccessor as y };