@jterrazz/test 15.1.1 → 15.3.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/README.md CHANGED
@@ -147,19 +147,22 @@ test('shows the events feed behind its deep link', async () => {
147
147
  });
148
148
  ```
149
149
 
150
- Actions are **terminal**: `.request()`, `.get()`, `.trigger()`, `.exec()`, `.fetch()`, `.visit()`, `.open()` execute the spec and resolve to a precisely typed result. There is no `.run()`, no label, and no `.spawn()`.
150
+ Actions are **terminal**: `.request()`, `.get()`, `.trigger()`, `.exec()`, `.call()`, `.fetch()`, `.visit()`, `.open()` execute the spec and resolve to a precisely typed result. There is no `.run()`, no label, and no `.spawn()`.
151
151
 
152
- ## The five constructors
152
+ ## The six constructors
153
153
 
154
154
  One constructor per tested interface, each returning a record destructured with its canonical name:
155
155
 
156
- | Constructor | Returns | Terminal actions |
157
- | --------------------------------- | ---------------------------------------- | ------------------------------------------------------------ |
158
- | `specification.api(options)` | `{ api, cleanup, docker, orchestrator }` | `.request(file)`, `.get()`, `.post()`, `.put()`, `.delete()` |
159
- | `specification.jobs(options)` | `{ jobs, cleanup, orchestrator }` | `.trigger(name)` |
160
- | `specification.cli(bin, options)` | `{ cli, cleanup, docker, orchestrator }` | `.exec(args, { waitFor?, timeout? }?)` |
161
- | `specification.website(options)` | `{ website, cleanup, url }` | `.fetch(path)`, `.visit(path, scenario?)` |
162
- | `specification.mobile(options)` | `{ mobile, cleanup, udid }` | `.open(deepLink?, scenario?)` |
156
+ | Constructor | Returns | Terminal actions |
157
+ | ---------------------------------- | ---------------------------------------- | ------------------------------------------------------------ |
158
+ | `specification.api(options)` | `{ api, cleanup, docker, orchestrator }` | `.request(file)`, `.get()`, `.post()`, `.put()`, `.delete()` |
159
+ | `specification.jobs(options)` | `{ jobs, cleanup, orchestrator }` | `.trigger(name)` |
160
+ | `specification.cli(bin, options)` | `{ cli, cleanup, docker, orchestrator }` | `.exec(args, { waitFor?, timeout? }?)` |
161
+ | `specification.integration(opts?)` | `{ integration, cleanup }` | `.call((services) => …)` |
162
+ | `specification.website(options)` | `{ website, cleanup, url }` | `.fetch(path)`, `.visit(path, scenario?)` |
163
+ | `specification.mobile(options)` | `{ mobile, cleanup, udid }` | `.open(deepLink?, scenario?)` |
164
+
165
+ A rendered component has no constructor: it starts nothing, so it is reached through the `component` chain directly (docs/16).
163
166
 
164
167
  ### `specification.api({ services, server, mode?, root? })`
165
168
 
@@ -292,16 +295,17 @@ Every artefact a run produces lands under `.artifacts/<tool>/` — one `.gitigno
292
295
 
293
296
  ### Setup (chainable)
294
297
 
295
- | Method | Facets | Description |
296
- | --------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------- |
297
- | `.seed("file.sql", { database? })` | all | Load SQL from `_seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
298
- | `.fixture("file")` | cli | Copy the feature-local `_fixtures/file` into the working directory |
299
- | `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/_fixtures/<name>/` project into the cwd (trailing `/` = contents; layers) |
300
- | `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
301
- | `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
302
- | `.intercept(contracts)` | all but cli | Declare the world: a `defineContracts(...)` composite — MSW on api/jobs, the stub backend on website/mobile |
303
- | `.intercept(contract)` / `([a, b])` | all but cli | A single contract, or an ordered list |
304
- | `.intercept(request, response)` | all but cli | Inline pair, for one-off plumbing |
298
+ | Method | Facets | Description |
299
+ | --------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------- |
300
+ | `.seed("file.sql", { database? })` | all | Load SQL from `_seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
301
+ | `.fixture("file")` | cli | Copy the feature-local `_fixtures/file` into the working directory |
302
+ | `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/_fixtures/<name>/` project into the cwd (trailing `/` = contents; layers) |
303
+ | `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
304
+ | `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
305
+ | `.intercept(contracts)` | all but cli | Declare the world: a `defineContracts(...)` composite — MSW on api/jobs, the stub backend on website/mobile |
306
+ | `.intercept(contract)` / `([a, b])` | all but cli | A single contract, or an ordered list |
307
+ | `.intercept(request, response)` | all but cli | Inline pair, for one-off plumbing |
308
+ | `.clock("2026-03-04T09:30:00Z")` | api, jobs, integration, website | Pin the calendar for this chain — the page's on website, this process's elsewhere |
305
309
 
306
310
  ### Actions (terminal)
307
311
 
@@ -444,23 +448,28 @@ test('deploy spawns a labelled container', async () => {
444
448
 
445
449
  `sqlite()` caches its schema template inside the project — `.artifacts/vitest/sqlite/template-<key>.sqlite` — so two checkouts never share one, and workers racing for a cold cache wait for the one that is building rather than all building at once. Details: [docs/11-services.md](docs/11-services.md#where-the-template-lives).
446
450
 
447
- ## Mocking utilities
451
+ ## Also exported
448
452
 
449
- ```typescript
450
- import { mockOf, mockOfDate } from '@jterrazz/test';
451
- ```
453
+ Everything below comes from the package root (rule F1) — except the project helpers, which are `@jterrazz/test/vitest`'s. One line each; the chapter owns the detail.
452
454
 
453
- | Export | Description |
454
- | ------------- | ------------------------------------------------------------ |
455
- | `mockOf<T>()` | Deep mock of any interface (wraps `vitest-mock-extended`) |
456
- | `mockOfDate` | Freeze/reset the global Date via `.set(date)` and `.reset()` |
455
+ | Export | Is | Chapter |
456
+ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
457
+ | `clock.at(iso)` · `.run(iso?)` · `.advance(ms)` | The one time primitive — a `using` scope that pins `Date`, replacing `vi.useFakeTimers()` and `mockOfDate` | [docs/12](docs/12-conventions.md#time--one-primitive-two-depths) |
458
+ | `intercept(…)` | The same network double with no chain: `await using _ = await intercept(…)` in a module test | [docs/10](docs/10-contracts.md#intercept--the-same-double-with-no-chain) |
459
+ | `process({ command, ready?, … })` | The one shape an external process takes — a dev server, an API, a bundler, owned by the runner | [docs/11](docs/11-services.md#process--the-one-shape-an-external-process-takes) |
460
+ | `http.stream(chunks, init?)` · `http.sse(events)` | A chunked or server-sent response body a contract replies with | [docs/10](docs/10-contracts.md) |
461
+ | `mockOf<T>()` · `mockOf<T>({ deep: false })` | Deep double of a port (a member never stubbed answers with another double), or the flat one | [docs/12](docs/12-conventions.md#the-doubles-ladder) |
462
+ | `mockOfDate` | Freeze/reset the global `Date` via `.set(date)` / `.reset()` — `clock` is what replaces it | [docs/12](docs/12-conventions.md#time--one-primitive-two-depths) |
463
+ | `required(value, why)` | The value, or a failure naming what was missing and why it mattered — instead of `!` | [docs/08](docs/08-assertions.md#two-helpers-that-are-not-matchers) |
464
+ | `waitUntil(predicate, { timeout?, interval?, why? })` | Wait on a CONDITION, never a duration, on a real-time budget a pinned clock cannot starve | [docs/08](docs/08-assertions.md#two-helpers-that-are-not-matchers) |
465
+ | `unit()` `component()` `api()` `jobs()` `cli()` `integration()` `website()` `mobile()` | The vitest project helpers, each taking `{ include, exclude, timeout, serial }` on top of the project it already is | [docs/02](docs/02-developing.md#vitest-config-the-preset) |
457
466
 
458
467
  ## Conventions
459
468
 
460
469
  Normative rules live in the constitution ([docs/12-conventions.md](docs/12-conventions.md)); the generated per-rule catalogue is [docs/13-linting.md](docs/13-linting.md). A facet (`specs/<facet>/`) carries its runner(s) at its root and holds domain folders; the folder follows the assets:
461
470
 
462
471
  ```
463
- specs/<facet>/ # api | jobs | cli | integrations | lint
472
+ specs/<facet>/ # api | jobs | cli | integration | website | mobile
464
473
  ├── <facet>.specification.ts # runner(s) at the facet ROOT (rule C1)
465
474
  └── <domain>/ # a product command/area — 1..n test files
466
475
  ├── <aspect>.test.ts
@@ -485,7 +494,7 @@ These conventions are not just prose: the package ships an oxlint plugin (`@jter
485
494
  - **vitest** - peer dependency
486
495
  - **playwright** - optional peer dependency, only needed for `.visit()`: `npm install -D playwright && npx playwright install chromium`
487
496
  - **appium + webdriverio** - optional peer dependencies, only needed for `specification.mobile()`: `npm install -D appium webdriverio && npx appium driver install xcuitest` — plus Xcode, a simulator, and the app installed on it
488
- - **msw** - bundled as a direct dependency (powers `.intercept()`); no separate install
497
+ - **msw** - bundled as a direct dependency (powers `.intercept()` on a chain and `intercept()` in module scope, under node and in a page); no separate install
489
498
  - **hono** (or any web framework) - supplied by your project for in-process apps; the adapter only needs an object with a `request()` method, so it is not a peer
490
499
 
491
500
  ## Docs
package/dist/ambiguity.js CHANGED
@@ -19,14 +19,20 @@ const CONSTRUCTORS = {
19
19
  button: "button",
20
20
  complementary: "complementary",
21
21
  contentinfo: "contentinfo",
22
+ dialog: "dialog",
22
23
  field: "field",
23
24
  form: "form",
24
25
  heading: "heading",
25
26
  link: "link",
27
+ listitem: "listitem",
26
28
  main: "main",
27
29
  navigation: "navigation",
30
+ option: "option",
28
31
  region: "region",
32
+ row: "row",
29
33
  search: "search",
34
+ status: "status",
35
+ table: "table",
30
36
  testId: "testId",
31
37
  text: "content"
32
38
  };
@@ -62,12 +68,22 @@ function formatBare(element) {
62
68
  /** `1. <a href="/articles">Articles</a> in <nav>` — one evidence line per candidate. */
63
69
  function formatMatch(match, index) {
64
70
  const attribute = match.detail ? ` ${quoteDetail(match)}` : "";
71
+ const named = match.accessibleName ? ` named ${JSON.stringify(match.accessibleName)}` : "";
65
72
  const context = match.context ? ` in <${match.context}>` : "";
66
- return ` ${index + 1}. <${match.tag}${attribute}>${match.text}</${match.tag}>${context}`;
73
+ return ` ${index + 1}. <${match.tag}${attribute}>${match.text}</${match.tag}>${named}${context}`;
67
74
  }
68
75
  function quoteDetail(match) {
69
76
  return `${match.tag === "a" ? "href" : "name"}=${JSON.stringify(match.detail)}`;
70
77
  }
78
+ /** Each landmark the candidates sit in, and how many of them it holds. */
79
+ function landmarkCounts(matches) {
80
+ const counts = /* @__PURE__ */ new Map();
81
+ for (const match of matches) {
82
+ const landmark = match.context === void 0 ? void 0 : CONTEXT_LANDMARKS[match.context];
83
+ if (landmark !== void 0) counts.set(landmark, (counts.get(landmark) ?? 0) + 1);
84
+ }
85
+ return [...counts].toSorted(([, a], [, b]) => a - b);
86
+ }
71
87
  /**
72
88
  * The rewrites worth offering, in the order they should be tried. Scoping is
73
89
  * always available; `exact` only when it would actually narrow the set, and
@@ -76,9 +92,12 @@ function quoteDetail(match) {
76
92
  */
77
93
  function formatFixes(element, matches) {
78
94
  const fixes = [];
79
- const landmarks = [...new Set(matches.map((match) => match.context && CONTEXT_LANDMARKS[match.context]).filter((landmark) => Boolean(landmark)))];
80
- if (landmarks.length > 0 && !element.scope) fixes.push(`scope it within(${landmarks[0]}, ${formatBare(element)})${landmarks.length > 1 ? ` [also here: ${landmarks.slice(1).join(", ")}]` : ""}`);
81
- else if (!element.scope) fixes.push(`scope it within(main(), ${formatBare(element)})`);
95
+ const narrowing = landmarkCounts(matches).filter(([, count]) => count < matches.length);
96
+ if (!element.scope) {
97
+ const [first, ...rest] = narrowing;
98
+ const alsoHere = rest.length > 0 ? ` [also here: ${rest.map(([name]) => name).join(", ")}]` : "";
99
+ fixes.push(first === void 0 ? `scope it within(<a container holding only this one>, ${formatBare(element)})` : `scope it within(${first[0]}, ${formatBare(element)}) [leaves ${first[1]} of ${matches.length}]${alsoHere}`);
100
+ }
82
101
  if (!element.exact && element.name !== void 0) {
83
102
  const remaining = matches.filter((match) => match.text === element.name).length;
84
103
  if (remaining > 0 && remaining < matches.length) fixes.push(`exact name ${formatBare({
package/dist/artifacts.js CHANGED
@@ -1,82 +1,10 @@
1
1
  import { i as SpecSyntaxError, l as updateSpecFile, s as readSpecFile } from "./spec-document.js";
2
2
  import { a as copyPlan, n as Matcher, r as TOKEN_KINDS, t as CaptureScope } from "./match.js";
3
3
  import { cpSync, existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { execSync, spawn } from "node:child_process";
4
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
+ import { execSync, spawn, spawnSync } from "node:child_process";
6
6
  import { readdir } from "node:fs/promises";
7
7
  import { createServer } from "node:net";
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
8
  //#region src/specification/facets/_common/result/grep.ts
81
9
  /**
82
10
  * Extract text blocks from output that contain a pattern.
@@ -163,26 +91,6 @@ var TextAccessor = class TextAccessor {
163
91
  return this.text;
164
92
  }
165
93
  };
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
94
  //#endregion
187
95
  //#region src/specification/facets/_common/result/json.ts
188
96
  /**
@@ -215,7 +123,13 @@ var JsonAccessor = class {
215
123
  this.transform = transform;
216
124
  this.captures = captures ?? new CaptureScope();
217
125
  }
218
- /** The parsed JSON value. Throws if the text is not valid JSON. */
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
+ */
219
133
  get value() {
220
134
  const stripped = stripAnsiCodes(this.rawText);
221
135
  const source = this.transform ? this.transform(stripped) : stripped;
@@ -228,45 +142,35 @@ var JsonAccessor = class {
228
142
  }
229
143
  };
230
144
  //#endregion
231
- //#region src/integrations/docker/docker-lookup.ts
145
+ //#region src/specification/facets/_common/result/table.ts
232
146
  /**
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).
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();
237
156
  */
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 [];
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();
248
168
  }
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
- }
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
+ };
270
174
  //#endregion
271
175
  //#region src/specification/matching/structural.ts
272
176
  /**
@@ -605,6 +509,46 @@ function mergeTextPreservingPlaceholders(previous, actual, scope) {
605
509
  return merged.join("\n");
606
510
  }
607
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
608
552
  //#region src/specification/facets/_common/result/directory.ts
609
553
  const DEFAULT_IGNORES = [
610
554
  ".git",
@@ -811,36 +755,6 @@ function expandWorkdir(env, workDir) {
811
755
  return expanded;
812
756
  }
813
757
  //#endregion
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
758
  //#region src/specification/facets/_common/result/result.ts
845
759
  /**
846
760
  * Base result - common accessors available after any action type.
@@ -1351,6 +1265,16 @@ function shouldUpdateSnapshots() {
1351
1265
  }
1352
1266
  /** Standard hint appended to missing-fixture errors. */
1353
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
+ }
1354
1278
  //#endregion
1355
1279
  //#region src/specification/facets/website/serve.adapter.ts
1356
1280
  /** Grace period between SIGTERM and the SIGKILL escalation. */
@@ -1408,14 +1332,16 @@ var ServeAdapter = class {
1408
1332
  }
1409
1333
  /** Start the server and resolve with its base URL once it is ready. */
1410
1334
  async start() {
1335
+ this.runBefore();
1411
1336
  const banner = this.options.ready instanceof RegExp ? this.options.ready : null;
1412
1337
  const port = banner ? null : this.options.port ?? await findFreePort();
1413
1338
  const timeout = this.options.timeout ?? DEFAULT_READY_TIMEOUT;
1414
1339
  this.child = spawn(this.options.command, [], {
1415
- cwd: this.root,
1340
+ cwd: this.workingDirectory(),
1416
1341
  detached: process.platform !== "win32",
1417
1342
  env: {
1418
1343
  ...process.env,
1344
+ ...typeof this.options.env === "function" ? {} : this.options.env,
1419
1345
  ...this.extraEnv,
1420
1346
  ...port === null ? {} : { PORT: String(port) }
1421
1347
  },
@@ -1476,6 +1402,25 @@ var ServeAdapter = class {
1476
1402
  }
1477
1403
  }
1478
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
+ }
1479
1424
  /** Terminate the server process group (idempotent). */
1480
1425
  async stop() {
1481
1426
  const { child } = this;
@@ -1794,4 +1739,4 @@ const COVERAGE_DIR = `${VITEST_ARTIFACTS_DIR}/coverage`;
1794
1739
  /** Where `sqlite()` caches the schema template it copies per worker. */
1795
1740
  const SQLITE_TEMPLATE_DIR = `${VITEST_ARTIFACTS_DIR}/sqlite`;
1796
1741
  //#endregion
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 };
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 };