@jterrazz/test 15.3.0 → 16.0.0

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