@jterrazz/test 15.0.0 → 15.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/artifacts.js CHANGED
@@ -4,9 +4,271 @@ import { cpSync, existsSync, readFileSync, realpathSync, statSync, writeFileSync
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { execSync, spawn } from "node:child_process";
6
6
  import { readdir } from "node:fs/promises";
7
- import { fileURLToPath } from "node:url";
8
7
  import { createServer } from "node:net";
9
- //#region src/core/matching/structural.ts
8
+ //#region src/specification/facets/_common/caller.ts
9
+ /** Sibling module tests are callers, not internals (CONVENTIONS I2). */
10
+ const TEST_FILE = /\.test\.[cm]?[jt]s$/u;
11
+ /** Where this very module sits inside the framework's SOURCE tree. */
12
+ const SOURCE_LOCATION = join("specification", "facets", "_common");
13
+ /** The real path of `path`, or `path` itself when it cannot be resolved. */
14
+ function realPath(path) {
15
+ try {
16
+ return realpathSync(path);
17
+ } catch {
18
+ return path;
19
+ }
20
+ }
21
+ /**
22
+ * Directory of the running framework module itself. From the built package
23
+ * this is `<package root>/dist`, whatever the resolution path — including a
24
+ * `file:` link, where the real path carries neither `node_modules` nor
25
+ * `/src/…` and would otherwise be mistaken for a caller frame.
26
+ */
27
+ const FRAMEWORK_DIR = realPath(import.meta.dirname);
28
+ /**
29
+ * The directory holding the framework's OWN modules: `<package>/dist` for a
30
+ * built install (everything is bundled there), `<repo>/src` when the framework
31
+ * runs from source — this module's known location inside that tree is what
32
+ * tells the two apart.
33
+ */
34
+ const FRAMEWORK_TREE = FRAMEWORK_DIR.endsWith(`${sep}${SOURCE_LOCATION}`) ? resolve(FRAMEWORK_DIR, "../../..") : FRAMEWORK_DIR;
35
+ /** Is `path` inside `directory` (strictly — a directory is not inside itself)? */
36
+ function isInside(directory, path) {
37
+ const rest = relative(directory, path);
38
+ return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
39
+ }
40
+ /**
41
+ * Is this stack frame a module of the framework itself, rather than of the code
42
+ * calling it?
43
+ *
44
+ * Answered by IDENTITY: the frame is inside the framework's own directory.
45
+ * Answering it by substring — `/src/core/`, `/src/integrations/`, `/src/vitest/`
46
+ * — was answering a different question, "does this path look like the framework's
47
+ * layout", and a consumer that happens to have its own `src/core/` matched it.
48
+ * Its files were then skipped as framework internals, and fixture resolution
49
+ * anchored on the wrong directory (or fell through to the cwd) with nothing
50
+ * reported.
51
+ *
52
+ * @internal
53
+ */
54
+ function isFrameworkFrame(filePath, frameworkTree = FRAMEWORK_TREE) {
55
+ if (TEST_FILE.test(filePath)) return false;
56
+ return isInside(realPath(frameworkTree), realPath(filePath));
57
+ }
58
+ /**
59
+ * Detect the directory of the first stack frame that lives outside this
60
+ * package. Used to anchor fixture resolution (`_seeds/`, `_requests/`,
61
+ * `_expected/`, …) on the calling test / specification file.
62
+ *
63
+ * @internal
64
+ */
65
+ function getCallerDir() {
66
+ const { stack } = /* @__PURE__ */ new Error("caller detection");
67
+ if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
68
+ const lines = stack.split("\n");
69
+ for (const line of lines) {
70
+ const match = /at\s+(?:.*?\()?(?:file:\/\/)?(?<filePath>[^:)]+):\d+:\d+/u.exec(line);
71
+ if (!match?.groups?.filePath) continue;
72
+ const { filePath } = match.groups;
73
+ if (filePath.includes("node_modules")) continue;
74
+ if (isFrameworkFrame(filePath)) continue;
75
+ return resolve(filePath, "..");
76
+ }
77
+ return process.cwd();
78
+ }
79
+ //#endregion
80
+ //#region src/specification/facets/_common/result/grep.ts
81
+ /**
82
+ * Extract text blocks from output that contain a pattern.
83
+ * Splits by blank lines (how linter/compiler output is structured),
84
+ * returns only blocks matching the pattern.
85
+ *
86
+ * @example
87
+ * expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
88
+ * expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
89
+ */
90
+ function grep(output, pattern) {
91
+ return output.replaceAll(/\u001B\[[0-9;]*m/gu, "").split(/\n\s*\n/u).filter((block) => block.includes(pattern)).join("\n\n");
92
+ }
93
+ //#endregion
94
+ //#region src/specification/facets/_common/result/text.ts
95
+ const ANSI_RE = /\u001B\[[0-9;]*m/gu;
96
+ /** Strip ANSI escape sequences from a string. */
97
+ function stripAnsiCodes(value) {
98
+ return value.replace(ANSI_RE, "");
99
+ }
100
+ /**
101
+ * Read-only accessor for a captured text handle — THE universal one: stdout,
102
+ * stderr, container logs, and file text all surface as a `TextAccessor`.
103
+ *
104
+ * Backed by a primitive string, exposed via {@link text}, but also provides
105
+ * `toString()` / `valueOf()` for string coercion, so common patterns like
106
+ * `String(result.stdout)` and template-literal interpolation still work.
107
+ *
108
+ * ANSI escape sequences are stripped by default before every comparison
109
+ * (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
110
+ * an escape hatch for application noise not covered by the `{{token}}`
111
+ * grammar.
112
+ *
113
+ * Text operations are **closed** over the type: `.grep(pattern)` returns a
114
+ * `TextAccessor` (not a bare string), preserving the `_expected/`-resolution
115
+ * context and the `transform`, so results are chainable
116
+ * (`result.stdout.grep(a).grep(b)`) and snapshot-able
117
+ * (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
118
+ *
119
+ * Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
120
+ * and `expect(result.stdout).toMatch('name.txt')` (resolved against
121
+ * `_expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
122
+ */
123
+ var TextAccessor = class TextAccessor {
124
+ /** @internal Ref-capture scope shared by the current spec execution. */
125
+ captures;
126
+ /** @internal Stream label used in failure messages ("stdout" / "stderr"). */
127
+ streamName;
128
+ /** @internal Test-file directory — fixture resolution root for matchers. */
129
+ testDir;
130
+ /** The raw captured text (never transformed, ANSI preserved). */
131
+ text;
132
+ /** @internal Normaliser applied before comparisons, never to fixtures. */
133
+ transform;
134
+ constructor(value, streamName, testDir, options = {}) {
135
+ this.text = value;
136
+ this.streamName = streamName;
137
+ this.testDir = testDir;
138
+ this.transform = options.transform;
139
+ this.captures = options.captures ?? new CaptureScope();
140
+ }
141
+ /** @internal The text as compared by matchers — ANSI stripped, transform applied. */
142
+ get comparableText() {
143
+ const stripped = stripAnsiCodes(this.text);
144
+ return this.transform ? this.transform(stripped) : stripped;
145
+ }
146
+ /**
147
+ * Keep only the blank-line-separated blocks of the text that contain
148
+ * `pattern` (how linter/compiler output is structured), returned as a new
149
+ * `TextAccessor` — the same subject type, so the result is chainable and
150
+ * snapshot-able. The scalpel for probing large tool outputs; snapshot the
151
+ * whole surface by default, reach for `.grep()` for targeted checks.
152
+ */
153
+ grep(pattern) {
154
+ return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
155
+ captures: this.captures,
156
+ transform: this.transform
157
+ });
158
+ }
159
+ toString() {
160
+ return this.text;
161
+ }
162
+ valueOf() {
163
+ return this.text;
164
+ }
165
+ };
166
+ /**
167
+ * Wrap an arbitrary string into a {@link TextAccessor} anchored on the calling
168
+ * test's directory — the same caller-detection the builders use.
169
+ *
170
+ * The product surface of a test framework is its own error messages, checker
171
+ * output, and reports; those deserve the same goldening as any other output.
172
+ * `text()` makes an ad-hoc string a first-class snapshot subject:
173
+ *
174
+ * ```typescript
175
+ * const message = await catchMessage(() => expect(result.response).toMatch('wrong-body.http'));
176
+ * expect(text(message)).toMatch('wrong-body-error.txt'); // resolves to _expected/
177
+ * ```
178
+ *
179
+ * ANSI is stripped before every comparison (the raw form stays on `.text`),
180
+ * the `{{token}}` grammar applies to the fixture, and `.grep()` composition
181
+ * works exactly as on any stream accessor.
182
+ */
183
+ function text(value) {
184
+ return new TextAccessor(value, "text", getCallerDir());
185
+ }
186
+ //#endregion
187
+ //#region src/specification/facets/_common/result/json.ts
188
+ /**
189
+ * Read-only accessor for a JSON payload parsed from a text stream (stdout).
190
+ *
191
+ * Lazily parses the JSON on first use; parse errors are deferred until the
192
+ * value is read so that callers can still read the raw stream text without
193
+ * triggering a throw.
194
+ *
195
+ * ANSI escape sequences are stripped by default before `JSON.parse`
196
+ * (CONVENTIONS D6). When a `transform` is configured on the spec runner, it
197
+ * runs on the stripped text — an escape hatch for application noise not
198
+ * covered by the `{{token}}` grammar.
199
+ *
200
+ * Assertions go through `expect()`: `expect(result.json).toMatch('name.json')`
201
+ * (deep-equal against `_expected/<name>`, with `{{placeholder}}` support).
202
+ */
203
+ var JsonAccessor = class {
204
+ /** @internal Ref-capture scope shared by the current spec execution. */
205
+ captures;
206
+ /** @internal */
207
+ rawText;
208
+ /** @internal Test-file directory — fixture resolution root for matchers. */
209
+ testDir;
210
+ /** @internal */
211
+ transform;
212
+ constructor(rawText, testDir, transform, captures) {
213
+ this.rawText = rawText;
214
+ this.testDir = testDir;
215
+ this.transform = transform;
216
+ this.captures = captures ?? new CaptureScope();
217
+ }
218
+ /** The parsed JSON value. Throws if the text is not valid JSON. */
219
+ get value() {
220
+ const stripped = stripAnsiCodes(this.rawText);
221
+ const source = this.transform ? this.transform(stripped) : stripped;
222
+ try {
223
+ return JSON.parse(source);
224
+ } catch {
225
+ const preview = source.slice(0, 200);
226
+ throw new Error(`stdout is not valid JSON: ${preview}`);
227
+ }
228
+ }
229
+ };
230
+ //#endregion
231
+ //#region src/integrations/docker/docker-lookup.ts
232
+ /**
233
+ * Thin synchronous wrappers around the `docker` CLI used by the docker() spec
234
+ * mode. Sync is deliberate — the calls are fast, run only on the host, and
235
+ * keep the dispose path straightforward (`Symbol.asyncDispose` can still be
236
+ * async without needing these to be).
237
+ */
238
+ const DEFAULT_TIMEOUT = 1e4;
239
+ /** Return all container IDs (running or stopped) that carry `key=value`. */
240
+ function findContainersByLabel(key, value) {
241
+ try {
242
+ return execSync(`docker ps -aq -f label=${key}=${value}`, {
243
+ encoding: "utf8",
244
+ timeout: DEFAULT_TIMEOUT
245
+ }).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
246
+ } catch {
247
+ return [];
248
+ }
249
+ }
250
+ /** Return the raw `docker inspect` payload (object, not array) for a container. */
251
+ function inspectContainer(id) {
252
+ const raw = execSync(`docker inspect ${id}`, {
253
+ encoding: "utf8",
254
+ timeout: DEFAULT_TIMEOUT
255
+ });
256
+ const parsed = JSON.parse(raw);
257
+ return Array.isArray(parsed) ? parsed[0] : parsed;
258
+ }
259
+ /** Force-remove the given container IDs in a single call. Errors are swallowed. */
260
+ function removeContainers(ids) {
261
+ if (ids.length === 0) return;
262
+ try {
263
+ execSync(`docker rm -f ${ids.join(" ")}`, {
264
+ encoding: "utf8",
265
+ stdio: "pipe",
266
+ timeout: DEFAULT_TIMEOUT
267
+ });
268
+ } catch {}
269
+ }
270
+ //#endregion
271
+ //#region src/specification/matching/structural.ts
10
272
  /**
11
273
  * Structural comparison engine shared by every fixture matcher.
12
274
  *
@@ -59,9 +321,9 @@ const EMBEDDED_SOURCES = {
59
321
  url: URL_SOURCE,
60
322
  uuid: UUID_SOURCE
61
323
  };
62
- const wholeRe = (source) => new RegExp(`^(?:${source})$`);
324
+ const wholeRe = (source) => new RegExp(`^(?:${source})$`, "u");
63
325
  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");
326
+ const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "gu");
65
327
  /** Whether a fixture string contains at least one `{{placeholder}}`. */
66
328
  function hasPlaceholders(value) {
67
329
  PLACEHOLDER_RE.lastIndex = 0;
@@ -126,7 +388,7 @@ function placeholderSource(kind, scope) {
126
388
  return kind === "any" ? String.raw`[\s\S]*?` : String.raw`[^\n]*?`;
127
389
  }
128
390
  function escapeRegExp(text) {
129
- return text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
391
+ return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
130
392
  }
131
393
  function parsePlaceholderString(expected, scope) {
132
394
  PLACEHOLDER_RE.lastIndex = 0;
@@ -137,7 +399,7 @@ function parsePlaceholderString(expected, scope) {
137
399
  let count = 0;
138
400
  for (const found of expected.matchAll(PLACEHOLDER_RE)) {
139
401
  const kind = found.groups.kind;
140
- const ref = found.groups.ref;
402
+ const { ref } = found.groups;
141
403
  source += escapeRegExp(expected.slice(lastIndex, found.index));
142
404
  source += `(${placeholderSource(kind, scope)})`;
143
405
  refs.push({
@@ -154,7 +416,7 @@ function parsePlaceholderString(expected, scope) {
154
416
  }
155
417
  source += escapeRegExp(expected.slice(lastIndex));
156
418
  return {
157
- pattern: new RegExp(`^${source}$`),
419
+ pattern: new RegExp(`^${source}$`, "u"),
158
420
  refs,
159
421
  single,
160
422
  source
@@ -248,7 +510,7 @@ function textEquals(expected, actual, scope) {
248
510
  function textContains(expected, actual, scope) {
249
511
  if (!hasPlaceholders(expected)) return actual.includes(expected);
250
512
  const parsed = parsePlaceholderString(expected, scope);
251
- const found = new RegExp(parsed.source).exec(actual);
513
+ const found = new RegExp(parsed.source, "u").exec(actual);
252
514
  if (!found) return false;
253
515
  return parsed.refs.every((entry) => !entry.ref || recordRef(entry.ref, found[entry.index + 1], scope));
254
516
  }
@@ -322,6 +584,7 @@ function mergeTextPreservingPlaceholders(previous, actual, scope) {
322
584
  const prev = prevLines[prevIndex];
323
585
  if (prev === void 0 || !hasPlaceholders(prev)) return false;
324
586
  const line = actualLines[actualIndex];
587
+ if (line === void 0) return false;
325
588
  return textEquals(prev, line, new CaptureScope(scope.workdir)) || textEquals(prev, rawLines[actualIndex] ?? line, new CaptureScope(scope.workdir));
326
589
  };
327
590
  const merged = actualLines.map(() => void 0);
@@ -342,187 +605,7 @@ function mergeTextPreservingPlaceholders(previous, actual, scope) {
342
605
  return merged.join("\n");
343
606
  }
344
607
  //#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
608
+ //#region src/specification/facets/_common/result/directory.ts
526
609
  const DEFAULT_IGNORES = [
527
610
  ".git",
528
611
  ".DS_Store",
@@ -599,340 +682,47 @@ var DirectoryAccessor = class {
599
682
  captures;
600
683
  /** @internal Absolute path of the directory under assertion. */
601
684
  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
- }
685
+ /** @internal Test-file directory — fixture resolution root for matchers. */
686
+ testDir;
687
+ constructor(absPath, testDir, captures) {
688
+ this.root = absPath;
689
+ this.testDir = testDir;
690
+ this.captures = captures ?? new CaptureScope();
691
+ }
692
+ /** List all files (recursively) under the directory, sorted. */
693
+ async files(options = {}) {
694
+ return await walkDirectory(this.root, options);
862
695
  }
863
696
  };
864
697
  //#endregion
865
- //#region src/core/specification/shared/result/table.ts
698
+ //#region src/specification/facets/_common/result/filesystem.ts
866
699
  /**
867
- * Read-only accessor for a database table after a specification run.
868
- *
869
- * Assertions go through `expect()` (async — they query the database):
700
+ * Read-only accessor for the whole temporary working directory used by a
701
+ * command spec.
870
702
  *
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();
703
+ * Assertions go through `expect()` (async — they walk the disk):
704
+ * `await expect(result.filesystem).toMatch('scaffolded')` compares the
705
+ * tree against the fixture directory `_expected/<name>/`.
876
706
  */
877
- var TableAccessor = class {
707
+ var FilesystemAccessor = class {
878
708
  /** @internal Ref-capture scope shared by the current spec execution. */
879
709
  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;
710
+ /** The absolute path of the temporary working directory. */
711
+ cwd;
712
+ /** @internal Test-file directory — fixture resolution root for matchers. */
713
+ testDir;
714
+ constructor(cwd, testDir, captures) {
715
+ this.cwd = cwd;
716
+ this.testDir = testDir;
887
717
  this.captures = captures ?? new CaptureScope();
888
718
  }
889
- /** @internal Query the table — used by the toMatchRows / toBeEmpty matchers. */
890
- query(columns) {
891
- return this.db.query(this.name, columns);
719
+ /** List all files (recursively) under the working directory, sorted. */
720
+ async files(options = {}) {
721
+ return await walkDirectory(this.cwd, options);
892
722
  }
893
723
  };
894
724
  //#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/resolve.ts
725
+ //#region src/specification/facets/_common/resolve.ts
936
726
  /** What makes a directory a project root — a manifest, or a test stack. */
937
727
  const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
938
728
  /**
@@ -1017,11 +807,41 @@ function safeRealpath(path) {
1017
807
  function expandWorkdir(env, workDir) {
1018
808
  const workdir = safeRealpath(workDir);
1019
809
  const expanded = {};
1020
- for (const [key, value] of Object.entries(env)) expanded[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workdir) : value;
810
+ for (const [key, value] of Object.entries(env)) expanded[key] = typeof value === "string" ? value.replaceAll("$WORKDIR", workdir) : value;
1021
811
  return expanded;
1022
812
  }
1023
813
  //#endregion
1024
- //#region src/core/specification/shared/result/result.ts
814
+ //#region src/specification/facets/_common/result/table.ts
815
+ /**
816
+ * Read-only accessor for a database table after a specification run.
817
+ *
818
+ * Assertions go through `expect()` (async — they query the database):
819
+ *
820
+ * await expect(result.table('users', { database: 'db' })).toMatchRows({
821
+ * columns: ['name'],
822
+ * rows: [['Alice']],
823
+ * });
824
+ * await expect(result.table('users', { database: 'db' })).toBeEmpty();
825
+ */
826
+ var TableAccessor = class {
827
+ /** @internal Ref-capture scope shared by the current spec execution. */
828
+ captures;
829
+ /** @internal */
830
+ db;
831
+ /** The table name this accessor reads. */
832
+ name;
833
+ constructor(name, db, captures) {
834
+ this.name = name;
835
+ this.db = db;
836
+ this.captures = captures ?? new CaptureScope();
837
+ }
838
+ /** @internal Query the table — used by the toMatchRows / toBeEmpty matchers. */
839
+ async query(columns) {
840
+ return await this.db.query(this.name, columns);
841
+ }
842
+ };
843
+ //#endregion
844
+ //#region src/specification/facets/_common/result/result.ts
1025
845
  /**
1026
846
  * Base result - common accessors available after any action type.
1027
847
  * Extended by HttpResult, CliResult, and used directly by job results.
@@ -1088,7 +908,7 @@ function validateDatabaseOption(method, config, database) {
1088
908
  if (keys.length === 1 && database) throw new Error(`${method}(): redundant database option — "${keys[0]}" is the only declared database.`);
1089
909
  }
1090
910
  //#endregion
1091
- //#region src/core/specification/cli/result.ts
911
+ //#region src/specification/facets/cli/result.ts
1092
912
  /**
1093
913
  * Result from a command action (`.exec()`).
1094
914
  *
@@ -1169,7 +989,7 @@ var CliResult = class extends BaseResult {
1169
989
  }
1170
990
  async [Symbol.asyncDispose]() {
1171
991
  if (!this.dockerConfig || !this.testRunId) return;
1172
- removeContainers(this.containersCache !== null ? [...this.containersCache.values()].map((c) => c.id) : findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId));
992
+ removeContainers(this.containersCache === null ? findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId) : [...this.containersCache.values()].map((c) => c.id));
1173
993
  }
1174
994
  ensureDockerAware(member) {
1175
995
  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.`);
@@ -1365,6 +1185,160 @@ var ContainerAccessor = class {
1365
1185
  }
1366
1186
  };
1367
1187
  //#endregion
1188
+ //#region src/specification/facets/_common/reporter.ts
1189
+ const GREEN = "\x1B[32m";
1190
+ const RED = "\x1B[31m";
1191
+ const DIM = "\x1B[2m";
1192
+ const BOLD = "\x1B[1m";
1193
+ const RESET = "\x1B[0m";
1194
+ const BG_CYAN = "\x1B[46m";
1195
+ const BLACK = "\x1B[30m";
1196
+ const CHECK = "✓";
1197
+ const CROSS = "×";
1198
+ const ARROW = "→";
1199
+ function formatStartupReport(mode, services, app) {
1200
+ const lines = [];
1201
+ lines.push("", `${BG_CYAN}${BLACK}${BOLD} INFRA ${RESET} Starting infrastructure...`, "");
1202
+ for (const service of services) if (service.error) {
1203
+ lines.push(` ${RED}${CROSS}${RESET} ${service.type} (${service.name}) ${RED}${service.error}${RESET} ${DIM}${service.durationMs}ms${RESET}`);
1204
+ if (service.logs) {
1205
+ const logLines = service.logs.trim().split("\n").slice(-10);
1206
+ for (const logLine of logLines) lines.push(` ${DIM}${logLine}${RESET}`);
1207
+ }
1208
+ } else {
1209
+ const conn = service.connectionString ? `${DIM}${service.connectionString}${RESET}` : "";
1210
+ lines.push(` ${GREEN}${CHECK}${RESET} ${service.type} (${service.name}) ${conn} ${DIM}${service.durationMs}ms${RESET}`);
1211
+ }
1212
+ if (app) {
1213
+ lines.push("");
1214
+ if (app.type === "in-process") lines.push(` ${DIM}${ARROW} app: in-process (Hono)${RESET}`);
1215
+ else lines.push(` ${DIM}${ARROW} app: ${app.url}${RESET}`);
1216
+ }
1217
+ lines.push("");
1218
+ return lines.join("\n");
1219
+ }
1220
+ function formatTableDiff(table, columns, expected, actual) {
1221
+ const lines = [];
1222
+ lines.push(`Table "${table}" mismatch`, `${DIM} query: ${columns.join(", ")}${RESET}`, `${DIM} expected: ${rowLabel(expected.length)}${RESET}`, `${DIM} received: ${rowLabel(actual.length)}${RESET}`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
1223
+ const header = columns.join(" | ");
1224
+ lines.push(`${DIM} ${header}${RESET}`);
1225
+ const maxRows = Math.max(expected.length, actual.length);
1226
+ for (let i = 0; i < maxRows; i++) {
1227
+ const exp = expected[i];
1228
+ const act = actual[i];
1229
+ if (exp && !act) lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
1230
+ else if (!exp && act) lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
1231
+ else if (exp && act) if (JSON.stringify(exp) === JSON.stringify(act)) lines.push(` ${formatRow(act)}`);
1232
+ else lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`, `${RED}+ ${formatRow(act)}${RESET}`);
1233
+ }
1234
+ if (expected.length === 0 && actual.length === 0) lines.push(` (empty)`);
1235
+ return lines.join("\n");
1236
+ }
1237
+ function formatResponseDiff(file, expected, actual) {
1238
+ const lines = [];
1239
+ lines.push(`Response mismatch (${file})`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
1240
+ const expectedLines = JSON.stringify(expected, null, 2).split("\n");
1241
+ const actualLines = JSON.stringify(actual, null, 2).split("\n");
1242
+ const maxLines = Math.max(expectedLines.length, actualLines.length);
1243
+ for (let i = 0; i < maxLines; i++) {
1244
+ const exp = expectedLines[i];
1245
+ const act = actualLines[i];
1246
+ if (exp === act) lines.push(` ${exp}`);
1247
+ else {
1248
+ if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
1249
+ if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
1250
+ }
1251
+ }
1252
+ return lines.join("\n");
1253
+ }
1254
+ const strictEquals = (expected, actual) => expected === actual;
1255
+ function renderLines(expectedLines, actualLines, equals) {
1256
+ const rendered = [];
1257
+ let marked = 0;
1258
+ const maxLines = Math.max(expectedLines.length, actualLines.length);
1259
+ for (let i = 0; i < maxLines; i++) {
1260
+ const exp = expectedLines[i];
1261
+ const act = actualLines[i];
1262
+ if (exp !== void 0 && act !== void 0 && equals(exp, act)) {
1263
+ rendered.push(` ${exp}`);
1264
+ continue;
1265
+ }
1266
+ marked++;
1267
+ if (exp !== void 0) rendered.push(`${GREEN}- ${exp}${RESET}`);
1268
+ if (act !== void 0) rendered.push(`${RED}+ ${act}${RESET}`);
1269
+ }
1270
+ return {
1271
+ marked,
1272
+ rendered
1273
+ };
1274
+ }
1275
+ /**
1276
+ * Render an expected/received diff over two texts.
1277
+ *
1278
+ * `equals` is how a line is judged EQUAL. Callers that compare through the
1279
+ * `{{token}}` grammar pass a token-aware predicate, so a `{{url}}` line that
1280
+ * matched is shown as equal — with its token, not as a `-`/`+` pair against
1281
+ * the concrete URL. Marking a line the comparison ACCEPTED buries the one it
1282
+ * rejected: the reader then has to work out which of the differences is the
1283
+ * actual cause.
1284
+ *
1285
+ * Safety net: if the token-aware pass marks nothing at all, the whole diff
1286
+ * would render as a clean block against a failure that really happened (a
1287
+ * `#ref` captured inconsistently across lines, say). The strict rendering is
1288
+ * used instead, so a diff always shows the reader something.
1289
+ */
1290
+ function formatStdoutDiff(file, expected, actual, options) {
1291
+ const lines = [];
1292
+ lines.push(`Output mismatch (${file})`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
1293
+ const expectedLines = expected.split("\n");
1294
+ const actualLines = actual.split("\n");
1295
+ const equals = options?.equals ?? strictEquals;
1296
+ const first = renderLines(expectedLines, actualLines, equals);
1297
+ const rendered = first.marked === 0 && equals !== strictEquals ? renderLines(expectedLines, actualLines, strictEquals).rendered : first.rendered;
1298
+ return [...lines, ...rendered].join("\n");
1299
+ }
1300
+ function formatDirectoryDiff(fixtureName, diff, hint) {
1301
+ const lines = [];
1302
+ const total = diff.added.length + diff.removed.length + diff.changed.length;
1303
+ lines.push(`Directory mismatch: ${BOLD}${fixtureName}${RESET}`, `${DIM} ${total} difference${total === 1 ? "" : "s"}: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed${RESET}`, "", `${GREEN}- Expected (fixture)${RESET}`, `${RED}+ Received (generated)${RESET}`, "");
1304
+ for (const path of diff.added) lines.push(`${RED}+ added ${path}${RESET} ${DIM}(not in fixture)${RESET}`);
1305
+ for (const path of diff.removed) lines.push(`${GREEN}- removed ${path}${RESET} ${DIM}(in fixture, not generated)${RESET}`);
1306
+ for (const { path, expected, actual } of diff.changed) {
1307
+ const expectedLines = expected.split("\n");
1308
+ const actualLines = actual.split("\n");
1309
+ const changedCount = countLineDifferences(expectedLines, actualLines);
1310
+ lines.push(`${BOLD}~ changed ${path}${RESET} ${DIM}(${changedCount} line${changedCount === 1 ? "" : "s"} differ)${RESET}`);
1311
+ let shown = 0;
1312
+ const maxShown = 5;
1313
+ const maxLines = Math.max(expectedLines.length, actualLines.length);
1314
+ for (let i = 0; i < maxLines && shown < maxShown; i++) {
1315
+ const exp = expectedLines[i];
1316
+ const act = actualLines[i];
1317
+ if (exp !== act) {
1318
+ lines.push(`${DIM} line ${i + 1}:${RESET}`);
1319
+ if (exp !== void 0) lines.push(` ${GREEN}- ${exp}${RESET}`);
1320
+ if (act !== void 0) lines.push(` ${RED}+ ${act}${RESET}`);
1321
+ shown++;
1322
+ }
1323
+ }
1324
+ if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
1325
+ }
1326
+ lines.push("", `${DIM}${hint}${RESET}`);
1327
+ return lines.join("\n");
1328
+ }
1329
+ function countLineDifferences(expected, actual) {
1330
+ let count = 0;
1331
+ const max = Math.max(expected.length, actual.length);
1332
+ for (let i = 0; i < max; i++) if (expected[i] !== actual[i]) count++;
1333
+ return count;
1334
+ }
1335
+ function rowLabel(n) {
1336
+ return n === 1 ? "1 row" : `${n} rows`;
1337
+ }
1338
+ function formatRow(row) {
1339
+ return row.map((v) => String(v ?? "null")).join(" | ");
1340
+ }
1341
+ //#endregion
1368
1342
  //#region src/vitest/update.ts
1369
1343
  /**
1370
1344
  * Snapshot update-mode detection. `TEST_UPDATE=1` (framework convention, see
@@ -1378,15 +1352,17 @@ function shouldUpdateSnapshots() {
1378
1352
  /** Standard hint appended to missing-fixture errors. */
1379
1353
  const UPDATE_HINT = "Run with TEST_UPDATE=1 (or vitest -u) to create it.";
1380
1354
  //#endregion
1381
- //#region src/core/specification/website/serve.adapter.ts
1355
+ //#region src/specification/facets/website/serve.adapter.ts
1382
1356
  /** Grace period between SIGTERM and the SIGKILL escalation. */
1383
1357
  const KILL_GRACE_MS = 2e3;
1384
1358
  const DEFAULT_READY_TIMEOUT = 3e4;
1385
1359
  const READY_POLL_INTERVAL_MS = 250;
1386
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1360
+ const delay = async (ms) => {
1361
+ await new Promise((resolve) => setTimeout(resolve, ms));
1362
+ };
1387
1363
  /** Ask the OS for a free TCP port. */
1388
- function findFreePort() {
1389
- return new Promise((resolve, reject) => {
1364
+ async function findFreePort() {
1365
+ return await new Promise((resolve, reject) => {
1390
1366
  const server = createServer();
1391
1367
  server.listen(0, () => {
1392
1368
  const address = server.address();
@@ -1396,7 +1372,9 @@ function findFreePort() {
1396
1372
  return;
1397
1373
  }
1398
1374
  const { port } = address;
1399
- server.close(() => resolve(port));
1375
+ server.close(() => {
1376
+ resolve(port);
1377
+ });
1400
1378
  });
1401
1379
  server.on("error", reject);
1402
1380
  });
@@ -1500,7 +1478,7 @@ var ServeAdapter = class {
1500
1478
  }
1501
1479
  /** Terminate the server process group (idempotent). */
1502
1480
  async stop() {
1503
- const child = this.child;
1481
+ const { child } = this;
1504
1482
  if (!child) return;
1505
1483
  this.child = null;
1506
1484
  await new Promise((resolve) => {
@@ -1532,7 +1510,7 @@ var ServeAdapter = class {
1532
1510
  }
1533
1511
  };
1534
1512
  //#endregion
1535
- //#region src/core/specification/cli/literate.ts
1513
+ //#region src/specification/facets/cli/literate.ts
1536
1514
  /**
1537
1515
  * The form a stream is compared in: ANSI stripped (rule D6) and the runner's
1538
1516
  * `transform` applied. Nothing else — a block scalar states its own final
@@ -1636,7 +1614,7 @@ function assertRun(document, outcome, context, workDir, scope) {
1636
1614
  ...outcome.stderr.length > 0 ? [
1637
1615
  "",
1638
1616
  "stderr was:",
1639
- outcome.stderr.replace(/\n$/, "")
1617
+ outcome.stderr.replace(/\n$/u, "")
1640
1618
  ] : []
1641
1619
  ].join("\n")]);
1642
1620
  const equals = tokenAwareEquals(scope);
@@ -1754,8 +1732,8 @@ async function runSpecDocument(options) {
1754
1732
  } catch (error) {
1755
1733
  throw error instanceof SpecSyntaxError ? atLine(error, filePath, error.line) : error;
1756
1734
  }
1757
- const document = file.document;
1758
- const fileDir = filePath.replace(/[/\\][^/\\]*$/, "");
1735
+ const { document } = file;
1736
+ const fileDir = filePath.replace(/[/\\][^/\\]*$/u, "");
1759
1737
  for (const fixture of document.fixtures) {
1760
1738
  const { dest, src } = copyPlan(fixture.path, fileDir, workDir);
1761
1739
  cpSync(src, dest, { recursive: true });
@@ -1816,4 +1794,4 @@ const COVERAGE_DIR = `${VITEST_ARTIFACTS_DIR}/coverage`;
1816
1794
  /** Where `sqlite()` caches the schema template it copies per worker. */
1817
1795
  const SQLITE_TEMPLATE_DIR = `${VITEST_ARTIFACTS_DIR}/sqlite`;
1818
1796
  //#endregion
1819
- export { formatResponseDiff as A, structuralSubset as B, text as C, diffDirectories as D, DirectoryAccessor as E, mergePreservingPlaceholders as F, mergeTextPreservingPlaceholders as I, placeholderPatternSource as L, formatStdoutDiff as M, formatTableDiff as N, walkDirectory as O, hasPlaceholders as P, renderExpected as R, TextAccessor as S, FilesystemAccessor as T, textEquals as V, findContainersByLabel as _, specDescription as a, TableAccessor as b, shouldUpdateSnapshots as c, BaseResult as d, validateDatabaseOption as f, resolveRoot as g, resolveCommand as h, runSpecDocument as i, formatStartupReport as j, formatDirectoryDiff as k, ContainerAccessor as l, expandWorkdir as m, SQLITE_TEMPLATE_DIR as n, ServeAdapter as o, discoverRoot as p, VITEST_ARTIFACTS_DIR as r, UPDATE_HINT as s, COVERAGE_DIR as t, CliResult as u, inspectContainer as v, getCallerDir as w, JsonAccessor as x, removeContainers as y, structuralEquals as z };
1797
+ export { placeholderPatternSource as A, text as B, FilesystemAccessor as C, hasPlaceholders as D, walkDirectory as E, findContainersByLabel as F, inspectContainer as I, removeContainers as L, structuralEquals as M, structuralSubset as N, mergePreservingPlaceholders as O, textEquals as P, JsonAccessor as R, resolveRoot as S, diffDirectories as T, getCallerDir as V, validateDatabaseOption as _, specDescription as a, expandWorkdir as b, shouldUpdateSnapshots as c, formatStartupReport as d, formatStdoutDiff as f, BaseResult as g, CliResult as h, runSpecDocument as i, renderExpected as j, mergeTextPreservingPlaceholders as k, formatDirectoryDiff as l, ContainerAccessor as m, SQLITE_TEMPLATE_DIR as n, ServeAdapter as o, formatTableDiff as p, VITEST_ARTIFACTS_DIR as r, UPDATE_HINT as s, COVERAGE_DIR as t, formatResponseDiff as u, TableAccessor as v, DirectoryAccessor as w, resolveCommand as x, discoverRoot as y, TextAccessor as z };