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