@jterrazz/test 15.2.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/index.js CHANGED
@@ -1,401 +1,335 @@
1
- import { a as copyPlan, c as GROUND_EXPECTED, d as GROUND_SEEDS, i as match, n as Matcher, t as CaptureScope, u as GROUND_REQUESTS } from "./match.js";
2
- import { A as mergePreservingPlaceholders, C as DirectoryAccessor, D as inspectContainer, E as findContainersByLabel, F as structuralSubset, I as textEquals, L as TableAccessor, N as renderExpected, O as removeContainers, P as structuralEquals, R as JsonAccessor, S as FilesystemAccessor, T as walkDirectory, _ as validateDatabaseOption, b as resolveCommand, c as shouldUpdateSnapshots, d as formatStartupReport, f as formatStdoutDiff, g as BaseResult, h as CliResult, i as runSpecDocument, j as mergeTextPreservingPlaceholders, l as formatDirectoryDiff, m as ContainerAccessor, n as SQLITE_TEMPLATE_DIR, o as ServeAdapter, p as formatTableDiff, s as UPDATE_HINT, u as formatResponseDiff, v as discoverRoot, w as diffDirectories, x as resolveRoot, y as expandWorkdir, z as TextAccessor } from "./artifacts.js";
3
- import { a as isContract, i as defineContracts, n as contractsOf, o as isContracts, r as defineContract, t as ContractQueue } from "./queue.js";
1
+ import { i as match, n as Matcher, t as CaptureScope } from "./match.js";
2
+ import { i as GROUND_EXPECTED, o as GROUND_REQUESTS, s as GROUND_SEEDS, t as copyPlan } from "./fixtures.js";
3
+ import { a as SQLITE_TEMPLATE_DIR } from "./artifacts.js";
4
+ import { a as isStreamBody, c as defineContracts, i as STREAM_BODY, l as isContract, n as ContractQueue, o as contractsOf, r as pause, s as defineContract, t as toReadableStream, u as isContracts } from "./stream.js";
5
+ import { C as FilesystemAccessor, E as walkDirectory, M as structuralEquals, N as structuralSubset, O as mergePreservingPlaceholders, P as textEquals, S as TextAccessor, T as diffDirectories, _ as discoverRoot, a as ServeAdapter, b as resolveRoot, c as formatStartupReport, d as ContainerAccessor, f as CliResult, g as TableAccessor, h as validateDatabaseOption, i as shouldUpdateSnapshots, j as renderExpected, k as mergeTextPreservingPlaceholders, l as formatStdoutDiff, m as BaseResult, o as formatDirectoryDiff, p as inspectContainer, r as missingHint, s as formatResponseDiff, t as runSpecDocument, u as formatTableDiff, v as expandWorkdir, w as DirectoryAccessor, x as JsonAccessor, y as resolveCommand } from "./literate.js";
4
6
  import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
5
7
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
- import { parse } from "yaml";
7
- import { execFile, execSync, spawn, spawnSync } from "node:child_process";
8
- import { Client } from "pg";
9
- import { mockDeep } from "vitest-mock-extended";
10
- import MockDatePackage from "mockdate";
11
- import { tmpdir } from "node:os";
8
+ import { execFile, spawn, spawnSync } from "node:child_process";
9
+ import { mock, mockDeep } from "vitest-mock-extended";
10
+ import { vi } from "vitest";
12
11
  import "node:url";
12
+ import { createHash, randomUUID } from "node:crypto";
13
+ import { tmpdir } from "node:os";
13
14
  import { createServer } from "node:http";
14
15
  import { promisify } from "node:util";
15
- import Database from "better-sqlite3";
16
- import { createHash } from "node:crypto";
17
- //#region src/integrations/compose/compose-parser.ts
18
- /**
19
- * Parse a docker-compose file and extract service definitions.
20
- */
21
- function parseComposeFile(filePath) {
22
- const doc = parse(readFileSync(filePath, "utf8"));
23
- if (!doc?.services) return {
24
- services: [],
25
- appService: null,
26
- infraServices: []
27
- };
28
- const services = Object.entries(doc.services).map(([name, def]) => {
29
- const ports = [];
30
- if (def.ports) for (const port of def.ports) {
31
- const str = String(port);
32
- if (str.includes(":")) {
33
- const [host, container] = str.split(":");
34
- ports.push({
35
- container: Number(container),
36
- host: Number(host)
37
- });
38
- } else ports.push({ container: Number(str) });
39
- }
40
- const environment = {};
41
- if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
42
- const [key, ...rest] = String(env).split("=");
43
- if (key !== void 0) environment[key] = rest.join("=");
44
- }
45
- else Object.assign(environment, def.environment);
46
- const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
47
- let dependsOn = [];
48
- if (def.depends_on) dependsOn = Array.isArray(def.depends_on) ? def.depends_on : Object.keys(def.depends_on);
49
- return {
50
- name,
51
- image: def.image,
52
- build: def.build,
53
- ports,
54
- environment,
55
- volumes,
56
- dependsOn
57
- };
58
- });
59
- return {
60
- services,
61
- appService: services.find((s) => s.build !== void 0) ?? null,
62
- infraServices: services.filter((s) => s.build === void 0)
63
- };
16
+ //#region src/model/chain/registry.ts
17
+ let containerIntegrations = null;
18
+ /** Wire the container runtime. Called by the package entry point. */
19
+ function registerContainerIntegrations(integrations) {
20
+ containerIntegrations = integrations;
21
+ }
22
+ function getContainerIntegrations() {
23
+ if (!containerIntegrations) throw new Error("@jterrazz/test: container integrations are not registered — import from '@jterrazz/test' (the package entry point wires them).");
24
+ return containerIntegrations;
64
25
  }
65
26
  //#endregion
66
- //#region src/integrations/compose/compose.ts
27
+ //#region src/model/contracts/intercept.ts
67
28
  /**
68
- * Start the full compose stack and stop it all on cleanup.
69
- * Supports per-worker project names for parallel execution.
29
+ * The network double in MODULE scope — the same contracts, the same queue and
30
+ * the same strictness as a chain's `.intercept()`, for the one kind of test
31
+ * that has no chain to hang them on.
32
+ *
33
+ * A module test whose subject reaches the network had exactly two ways out
34
+ * before this: `vi.stubGlobal('fetch')`, which replaces the platform with a
35
+ * hand-written stand-in, or `msw/node` imported directly, which F6 refuses a
36
+ * test and F8 refuses a consumer to declare. Both answer "what did the outside
37
+ * world reply" in a dialect nothing else in the repository speaks.
38
+ *
39
+ * The scope is an `AsyncDisposable`, so the handlers come off at the end of
40
+ * the block however the block ends — and the strict violation (an outgoing
41
+ * request no contract accepted) is raised THERE, which is what makes the
42
+ * double a guard rather than a convenience.
70
43
  */
71
- var ComposeStackAdapter = class {
72
- composeFile;
73
- projectName;
74
- started = false;
75
- constructor(composeFile, projectName) {
76
- this.composeFile = composeFile;
77
- this.projectName = projectName ?? null;
78
- }
79
- get projectFlag() {
80
- return this.projectName ? `-p ${this.projectName}` : "";
44
+ /**
45
+ * The contracts a call declared, whichever of the two forms it used. Exported
46
+ * for its own module test: the bare-request refusal is a runtime guard the
47
+ * compiler already states, so no typed call site can reach it.
48
+ *
49
+ * @internal
50
+ */
51
+ function declared(requestOrContracts, maybeResponse) {
52
+ if (Array.isArray(requestOrContracts) || isContracts(requestOrContracts) || isContract(requestOrContracts)) return contractsOf(requestOrContracts);
53
+ if (maybeResponse === void 0) throw new Error("intercept(): a bare request needs its response — pass a contract (defineContract({ request, response })) or the inline pair intercept(request, response).");
54
+ return [{
55
+ request: requestOrContracts,
56
+ response: maybeResponse
57
+ }];
58
+ }
59
+ /** Is this argument the options object rather than a response? */
60
+ function isOptions(value) {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "origin" in value;
62
+ }
63
+ /**
64
+ * Resolve every relative request against `origin` for the life of the scope.
65
+ *
66
+ * The framework owns the seam so a test never has to: this is the one place
67
+ * `fetch` is wrapped, it is put back when the scope ends, and what it does is
68
+ * the one thing a page would have done for free — turn `/api/posts` into a URL.
69
+ */
70
+ function resolveRelativeAgainst(origin) {
71
+ let base;
72
+ try {
73
+ base = new URL(origin);
74
+ } catch {
75
+ throw new Error(`intercept(): \`origin\` states where a relative request resolves — \`${origin}\` is not an absolute URL (e.g. 'http://console.test').`);
81
76
  }
82
- run(command) {
77
+ const original = globalThis.fetch;
78
+ globalThis.fetch = async (input, init) => typeof input === "string" && input.startsWith("/") ? await original(new URL(input, base).toString(), init) : await original(input, init);
79
+ return () => {
80
+ globalThis.fetch = original;
81
+ };
82
+ }
83
+ /**
84
+ * Build the module-scope double on one engine. The node entry hands it msw's
85
+ * server, the page's entry hands it msw's worker: one shape, two transports,
86
+ * exactly as a chain's `.intercept()` already has it.
87
+ *
88
+ * @internal
89
+ */
90
+ function interceptThrough(register) {
91
+ async function intercept(requestOrContracts, maybeResponseOrOptions, maybeOptions) {
92
+ const options = isOptions(maybeOptions) ? maybeOptions : isOptions(maybeResponseOrOptions) ? maybeResponseOrOptions : void 0;
93
+ const contracts = declared(requestOrContracts, options === maybeResponseOrOptions ? void 0 : maybeResponseOrOptions);
94
+ if (contracts.length === 0) throw new Error("intercept(): declare at least one contract — an empty list guards nothing, and a subject with no network needs no intercept at all.");
95
+ const restoreOrigin = options?.origin === void 0 ? void 0 : resolveRelativeAgainst(options.origin);
96
+ let registration;
83
97
  try {
84
- return execSync(command, {
85
- cwd: dirname(this.composeFile),
86
- encoding: "utf8",
87
- timeout: 12e4
88
- }).trim();
98
+ registration = await register(contracts);
89
99
  } catch (error) {
90
- const stderr = error.stderr?.toString().trim() ?? error.message;
91
- throw new Error(`docker compose failed: ${stderr}`, { cause: error });
100
+ restoreOrigin?.();
101
+ throw error;
92
102
  }
103
+ return { [Symbol.asyncDispose]: async () => {
104
+ const violation = registration.violation();
105
+ registration.cleanup();
106
+ restoreOrigin?.();
107
+ await Promise.resolve();
108
+ if (violation) throw violation;
109
+ } };
93
110
  }
94
- async start() {
95
- if (this.started) return;
96
- this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} up -d --wait --build`);
97
- this.started = true;
98
- }
99
- async stop() {
100
- if (!this.started) return;
101
- this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} down -v`);
102
- this.started = false;
111
+ return intercept;
112
+ }
113
+ //#endregion
114
+ //#region src/seams/peer.ts
115
+ /**
116
+ * Loading an OPTIONAL peer, and saying the right thing when it is not there.
117
+ *
118
+ * Four native seams — `better-sqlite3`, `pg`, `redis`, `testcontainers` — are
119
+ * peers rather than dependencies: a consumer that specifies a CLI has no
120
+ * business compiling a SQLite binding, and a package carrying all four would
121
+ * make every install pay for the services one repository happens to use.
122
+ *
123
+ * A peer being absent is then an ordinary state, and the message is what turns
124
+ * it into a one-line fix. Two things make it actionable:
125
+ *
126
+ * - it names the FACET that asked, so the reader knows which line to look at,
127
+ * and the peer to install, with the command of the package manager the
128
+ * project's own lockfile names — `npm install -D` in a Bun workspace is a
129
+ * line the reader has to translate before they can use it;
130
+ * - under pnpm it names `only-built-dependencies` as well. pnpm does not run a
131
+ * dependency's install script unless the manifest lists it, so a native
132
+ * binding installs and then fails to LOAD — the one failure where "install
133
+ * the package" is advice the reader has already followed.
134
+ */
135
+ /**
136
+ * The lockfile each package manager leaves at the project root, and the
137
+ * command a reader of this message types.
138
+ *
139
+ * A message that says `npm install` in a Bun workspace is a message the reader
140
+ * has to translate before they can use it — and the translation is the only
141
+ * part of the fix they did not already know.
142
+ */
143
+ const INSTALLERS = [
144
+ {
145
+ command: "bun add -d",
146
+ lockfile: "bun.lock",
147
+ name: "bun"
148
+ },
149
+ {
150
+ command: "bun add -d",
151
+ lockfile: "bun.lockb",
152
+ name: "bun"
153
+ },
154
+ {
155
+ command: "pnpm add -D",
156
+ lockfile: "pnpm-lock.yaml",
157
+ name: "pnpm"
158
+ },
159
+ {
160
+ command: "yarn add -D",
161
+ lockfile: "yarn.lock",
162
+ name: "yarn"
163
+ },
164
+ {
165
+ command: "npm install -D",
166
+ lockfile: "package-lock.json",
167
+ name: "npm"
168
+ }
169
+ ];
170
+ /** What npm's absence of a lockfile still answers. */
171
+ const DEFAULT_INSTALLER = {
172
+ command: "npm install -D",
173
+ name: "npm"
174
+ };
175
+ /**
176
+ * How each package manager is told to run the install script it withheld.
177
+ *
178
+ * Every one of them now withholds it by default — npm since 11, pnpm since 10,
179
+ * bun since 1.2 — so "install the package" is advice the reader has already
180
+ * followed and the binding is still not there.
181
+ */
182
+ const BUILDERS = {
183
+ bun: (peer) => `bun pm trust ${peer}`,
184
+ npm: (peer) => `npm install-scripts approve ${peer} && npm rebuild ${peer}`,
185
+ pnpm: (peer) => `add "${peer}" to \`onlyBuiltDependencies\` in package.json (or \`only-built-dependencies\` in .npmrc), then \`pnpm rebuild ${peer}\``,
186
+ yarn: (peer) => `yarn rebuild ${peer}`
187
+ };
188
+ /**
189
+ * What a native module that is INSTALLED but not BUILT says when it is first
190
+ * used. Node's own message for a missing `.node`, and the `bindings` package's
191
+ * for the same thing, plus the two shapes a binding built elsewhere produces.
192
+ */
193
+ const UNBUILT = /could not locate the bindings file|\.node['"]?$|invalid elf header|was compiled against a different node/iu;
194
+ /** Is this failure a binding that was never built, rather than a bug in the seam? */
195
+ function isUnbuilt(error) {
196
+ const message = error instanceof Error ? error.message : String(error);
197
+ return UNBUILT.test(message);
198
+ }
199
+ /** The peers whose install script has to run for the package to load at all. */
200
+ const NATIVE_PEERS = /* @__PURE__ */ new Set(["better-sqlite3"]);
201
+ /** The nearest ancestor of `from` carrying a `package.json`, or undefined. */
202
+ function projectRoot(from) {
203
+ let directory = from;
204
+ for (;;) {
205
+ if (existsSync(resolve(directory, "package.json"))) return directory;
206
+ const parent = dirname(directory);
207
+ if (parent === directory) return;
208
+ directory = parent;
103
209
  }
104
- getMappedPort(serviceName, containerPort) {
105
- const port = this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
106
- return Number(port);
210
+ }
211
+ /**
212
+ * The package manager a project is installed by, read from its lockfile.
213
+ *
214
+ * Exported for its own module test: the answer depends on the filesystem
215
+ * around the caller, which a test states by building one.
216
+ *
217
+ * @internal
218
+ */
219
+ function installerAt(from) {
220
+ const root = projectRoot(from);
221
+ if (root === void 0) return DEFAULT_INSTALLER;
222
+ return INSTALLERS.find((candidate) => existsSync(resolve(root, candidate.lockfile))) ?? DEFAULT_INSTALLER;
223
+ }
224
+ /** The line a reader has to add, when the package manager needs one. */
225
+ function buildNote(peer, manager) {
226
+ if (!NATIVE_PEERS.has(peer) || manager !== "pnpm") return "";
227
+ return ` Under pnpm the binding is not built unless the package is listed: add "${peer}" to \`onlyBuiltDependencies\` in package.json (or \`only-built-dependencies\` in .npmrc) and reinstall.`;
228
+ }
229
+ /**
230
+ * Use a native peer that is already loaded, and say the right thing when its
231
+ * BINDING was never built.
232
+ *
233
+ * A native peer has two ways of not being there, and only one of them is
234
+ * "install it". The other is the package present on disk with no compiled
235
+ * `.node` beside it, because the package manager withheld the install script —
236
+ * which every one of them now does by default. Left alone that surfaces as a
237
+ * `bindings` stack trace from whichever spec opened a database first: no peer
238
+ * name, no facet, no command. This turns it into the same shaped refusal a
239
+ * missing peer gets.
240
+ */
241
+ function requireBuiltPeer(peer, what, use) {
242
+ try {
243
+ use();
244
+ } catch (error) {
245
+ if (!isUnbuilt(error)) throw error;
246
+ const { name } = installerAt(process.cwd());
247
+ const build = (BUILDERS[name] ?? BUILDERS.npm)?.(peer) ?? `rebuild ${peer}`;
248
+ throw new Error(`${what} found \`${peer}\` but its native binding is not built — ${name} does not run a dependency's install script unless it is told to: ${build}.`, { cause: error });
107
249
  }
108
- getHost() {
109
- return "localhost";
250
+ }
251
+ /**
252
+ * Import an optional peer, or throw a message that names the facet, the peer
253
+ * and the command.
254
+ *
255
+ * `load` is the caller's own `import('<peer>')`: a bare specifier is what the
256
+ * bundler and the resolver both need to see, so the import stays at the call
257
+ * site and this function owns only the failure.
258
+ */
259
+ async function loadPeer(peer, what, load) {
260
+ try {
261
+ return await load();
262
+ } catch (error) {
263
+ const { command, name } = installerAt(process.cwd());
264
+ throw new Error(`${what} requires \`${peer}\`, an optional peer dependency of @jterrazz/test: ${command} ${peer}.${buildNote(peer, name)}`, { cause: error });
110
265
  }
111
- };
266
+ }
112
267
  //#endregion
113
- //#region src/integrations/postgres/postgres.ts
114
- var PostgresHandle = class {
115
- type = "postgres";
116
- composeName;
117
- defaultPort = 5432;
118
- defaultImage;
119
- environment;
120
- connectionString = "";
121
- started = false;
122
- client = null;
123
- originalConnectionString = "";
124
- schema = "public";
125
- constructor(options = {}) {
126
- this.composeName = options.composeService ?? null;
127
- this.defaultImage = options.image ?? "postgres:17";
128
- this.environment = {
129
- POSTGRES_DB: "test",
130
- POSTGRES_PASSWORD: "test",
131
- POSTGRES_USER: "test",
132
- ...options.env
133
- };
134
- }
135
- buildConnectionString(host, port) {
136
- return `postgresql://${this.environment.POSTGRES_USER ?? "test"}:${this.environment.POSTGRES_PASSWORD ?? "test"}@${host}:${port}/${this.environment.POSTGRES_DB ?? "test"}`;
137
- }
138
- createDatabaseAdapter() {
139
- return this;
268
+ //#region src/seams/testcontainers/testcontainers.ts
269
+ /**
270
+ * Container adapter using testcontainers.
271
+ * Wraps a GenericContainer for programmatic container lifecycle.
272
+ */
273
+ var TestcontainersAdapter = class {
274
+ image;
275
+ containerPort;
276
+ env;
277
+ reuse;
278
+ container = null;
279
+ constructor(options) {
280
+ this.image = options.image;
281
+ this.containerPort = options.port;
282
+ this.env = options.env ?? {};
283
+ this.reuse = options.reuse ?? false;
140
284
  }
141
- async healthcheck() {
142
- if (!this.connectionString) throw new Error("postgres: cannot healthcheck — no connection string");
143
- try {
144
- const client = new Client({ connectionString: this.connectionString });
145
- await client.connect();
146
- await client.query("SELECT 1");
147
- await client.end();
148
- } catch (error) {
149
- throw new Error(`postgres healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
150
- }
285
+ async start() {
286
+ const { GenericContainer, Wait } = await loadPeer("testcontainers", "a declared service", async () => await import("testcontainers"));
287
+ let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
288
+ for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
289
+ if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/u, 2));
290
+ if (this.reuse) builder = builder.withReuse();
291
+ this.container = await builder.start();
151
292
  }
152
- async initialize(composeDir) {
153
- if (!this.composeName) return;
154
- const initPaths = [resolve(composeDir, `${this.composeName}/init.sql`), resolve(composeDir, "postgres/init.sql")];
155
- for (const initPath of initPaths) if (existsSync(initPath)) {
156
- const sql = readFileSync(initPath, "utf8");
157
- try {
158
- await this.seed(sql);
159
- } catch (error) {
160
- throw new Error(`postgres init script failed (${initPath}):\n${error.message}`, { cause: error });
161
- }
162
- return;
293
+ async stop() {
294
+ if (this.container && !this.reuse) {
295
+ await this.container.stop();
296
+ this.container = null;
163
297
  }
164
298
  }
165
- async getClient() {
166
- if (this.client) return this.client;
167
- const client = new Client({ connectionString: this.connectionString });
168
- client.on("error", () => {
169
- this.client = null;
170
- });
171
- await client.connect();
172
- this.client = client;
173
- return client;
174
- }
175
- async seed(sql) {
176
- await (await this.getClient()).query(sql);
299
+ getMappedPort(containerPort) {
300
+ if (!this.container) throw new Error("Container not started");
301
+ return this.container.getMappedPort(containerPort);
177
302
  }
178
- async reset() {
179
- const client = await this.getClient();
180
- const result = await client.query(`
181
- SELECT tablename FROM pg_tables
182
- WHERE schemaname = '${this.schema}'
183
- AND tablename NOT LIKE '_prisma%'
184
- `);
185
- for (const row of result.rows) await client.query(`TRUNCATE "${this.schema}"."${row.tablename}" CASCADE`);
303
+ getHost() {
304
+ if (!this.container) throw new Error("Container not started");
305
+ return this.container.getHost();
186
306
  }
187
- async query(table, columns) {
188
- const client = await this.getClient();
189
- const columnList = columns.join(", ");
190
- return (await client.query(`SELECT ${columnList} FROM "${this.schema}"."${table}" ORDER BY 1`)).rows.map((row) => columns.map((col) => row[col]));
307
+ getConnectionString() {
308
+ return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
191
309
  }
192
- isolation() {
193
- return {
194
- acquire: async (workerId) => {
195
- const workerSchema = `worker_${workerId}`;
196
- this.originalConnectionString = this.connectionString;
197
- const client = await this.getClient();
198
- await client.query(`DROP SCHEMA IF EXISTS "${workerSchema}" CASCADE`);
199
- await client.query(`CREATE SCHEMA "${workerSchema}"`);
200
- const tables = await client.query(`
201
- SELECT tablename FROM pg_tables
202
- WHERE schemaname = 'public'
203
- AND tablename NOT LIKE '_prisma%'
204
- `);
205
- for (const row of tables.rows) await client.query(`CREATE TABLE "${workerSchema}"."${row.tablename}" (LIKE "public"."${row.tablename}" INCLUDING ALL)`);
206
- this.schema = workerSchema;
207
- await client.query(`SET search_path TO "${workerSchema}", public`);
208
- const url = new URL(this.connectionString);
209
- url.searchParams.set("options", `-c search_path=${workerSchema},public`);
210
- this.connectionString = url.toString();
211
- },
212
- reset: async () => {
213
- await this.reset();
214
- },
215
- release: async () => {
216
- const client = await this.getClient();
217
- const workerSchema = this.schema;
218
- this.schema = "public";
219
- this.connectionString = this.originalConnectionString;
220
- await client.query(`SET search_path TO public`);
221
- await client.query(`DROP SCHEMA IF EXISTS "${workerSchema}" CASCADE`);
222
- }
223
- };
310
+ async getLogs() {
311
+ if (!this.container) return "";
312
+ const stream = await this.container.logs();
313
+ return await new Promise((resolve) => {
314
+ let output = "";
315
+ stream.on("data", (chunk) => {
316
+ output += chunk.toString();
317
+ });
318
+ stream.on("end", () => {
319
+ resolve(output);
320
+ });
321
+ setTimeout(() => {
322
+ resolve(output);
323
+ }, 1e3);
324
+ });
224
325
  }
225
326
  };
226
- /**
227
- * Create a PostgreSQL service handle.
228
- *
229
- * @example
230
- * // Key derives the compose service (exact name or kebab-case):
231
- * // { db: postgres() } → compose service "db"
232
- * // { analyticsDb: postgres() } → compose service "analytics-db"
233
- * // Escape hatch for names the key cannot derive:
234
- * const events = postgres({ composeService: "legacy_event_store" });
235
- * // After start: events.connectionString is populated
236
- */
237
- function postgres(options = {}) {
238
- return new PostgresHandle(options);
239
- }
240
327
  //#endregion
241
- //#region src/integrations/redis/redis.ts
242
- var RedisHandle = class {
243
- type = "redis";
244
- composeName;
245
- defaultPort = 6379;
246
- defaultImage;
247
- environment = {};
248
- connectionString = "";
249
- started = false;
250
- dbIndex = 0;
251
- constructor(options = {}) {
252
- this.composeName = options.composeService ?? null;
253
- this.defaultImage = options.image ?? "redis:7";
254
- }
255
- buildConnectionString(host, port) {
256
- return `redis://${host}:${port}`;
257
- }
258
- createDatabaseAdapter() {
259
- return null;
260
- }
261
- async healthcheck() {
262
- if (!this.connectionString) throw new Error("redis: cannot healthcheck — no connection string");
263
- try {
264
- const { createClient } = await import("redis");
265
- const client = createClient({ url: this.connectionString });
266
- await client.connect();
267
- await client.ping();
268
- await client.disconnect();
269
- } catch (error) {
270
- throw new Error(`redis healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
271
- }
272
- }
273
- async initialize() {}
274
- async reset() {
275
- const { createClient } = await import("redis");
276
- const client = createClient({
277
- url: this.connectionString,
278
- database: this.dbIndex
279
- });
280
- await client.connect();
281
- try {
282
- await client.flushDb();
283
- } finally {
284
- await client.disconnect();
285
- }
286
- }
287
- isolation() {
288
- return {
289
- acquire: async (workerId) => {
290
- const numericId = Number.parseInt(workerId, 10) || 0;
291
- this.dbIndex = numericId % 15 + 1;
292
- },
293
- reset: async () => {
294
- await this.reset();
295
- },
296
- release: async () => {
297
- await this.reset();
298
- this.dbIndex = 0;
299
- }
300
- };
301
- }
302
- };
303
- /**
304
- * Create a Redis service handle.
305
- *
306
- * @example
307
- * // The record key derives the compose service: { cache: redis() } → "cache".
308
- * const cache = redis();
309
- * // After start: cache.connectionString is populated
310
- */
311
- function redis(options = {}) {
312
- return new RedisHandle(options);
313
- }
314
- //#endregion
315
- //#region src/integrations/testcontainers/testcontainers.ts
316
- /**
317
- * Container adapter using testcontainers.
318
- * Wraps a GenericContainer for programmatic container lifecycle.
319
- */
320
- var TestcontainersAdapter = class {
321
- image;
322
- containerPort;
323
- env;
324
- reuse;
325
- container = null;
326
- constructor(options) {
327
- this.image = options.image;
328
- this.containerPort = options.port;
329
- this.env = options.env ?? {};
330
- this.reuse = options.reuse ?? false;
331
- }
332
- async start() {
333
- const { GenericContainer, Wait } = await import("testcontainers");
334
- let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
335
- for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
336
- if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/u, 2));
337
- if (this.reuse) builder = builder.withReuse();
338
- this.container = await builder.start();
339
- }
340
- async stop() {
341
- if (this.container && !this.reuse) {
342
- await this.container.stop();
343
- this.container = null;
344
- }
345
- }
346
- getMappedPort(containerPort) {
347
- if (!this.container) throw new Error("Container not started");
348
- return this.container.getMappedPort(containerPort);
349
- }
350
- getHost() {
351
- if (!this.container) throw new Error("Container not started");
352
- return this.container.getHost();
353
- }
354
- getConnectionString() {
355
- return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
356
- }
357
- async getLogs() {
358
- if (!this.container) return "";
359
- const stream = await this.container.logs();
360
- return await new Promise((resolve) => {
361
- let output = "";
362
- stream.on("data", (chunk) => {
363
- output += chunk.toString();
364
- });
365
- stream.on("end", () => {
366
- resolve(output);
367
- });
368
- setTimeout(() => {
369
- resolve(output);
370
- }, 1e3);
371
- });
372
- }
373
- };
374
- //#endregion
375
- //#region src/specification/facets/_common/registry.ts
376
- let containerIntegrations = null;
377
- const composeServiceFactories = /* @__PURE__ */ new Map();
378
- /** Wire the container runtimes. Called by the package entry point. */
379
- function registerContainerIntegrations(integrations) {
380
- containerIntegrations = integrations;
381
- }
382
- /** Wire a service auto-detection factory (e.g. `'postgres'`, `'redis'`). */
383
- function registerComposeServiceFactory(type, factory) {
384
- composeServiceFactories.set(type, factory);
385
- }
386
- function getContainerIntegrations() {
387
- if (!containerIntegrations) throw new Error("@jterrazz/test: container integrations are not registered — import from '@jterrazz/test' (the package entry point wires them).");
388
- return containerIntegrations;
389
- }
390
- function getComposeServiceFactory(type) {
391
- return composeServiceFactories.get(type);
392
- }
393
- //#endregion
394
- //#region src/specification/facets/website/elements.ts
328
+ //#region src/model/elements/elements.ts
395
329
  const named = (kind) => (name, options) => ({
396
330
  kind,
397
331
  name,
398
- ...options?.exact ? { exact: true } : {}
332
+ ...options?.exact === void 0 ? {} : { exact: options.exact }
399
333
  });
400
334
  /** A button (or element with the button role), by accessible name. */
401
335
  const button = named("button");
@@ -415,7 +349,7 @@ const testId = (id) => ({
415
349
  const landmark = (kind) => (name, options) => ({
416
350
  kind,
417
351
  ...name === void 0 ? {} : { name },
418
- ...options?.exact ? { exact: true } : {}
352
+ ...options?.exact === void 0 ? {} : { exact: options.exact }
419
353
  });
420
354
  /** The `banner` landmark — the page header. */
421
355
  const banner = landmark("banner");
@@ -533,7 +467,7 @@ const valued = (element, value) => ({
533
467
  value
534
468
  });
535
469
  //#endregion
536
- //#region src/specification/contracts/filters.ts
470
+ //#region src/model/contracts/filters.ts
537
471
  /** Does an observed text satisfy a declared {@link TextFilter}? */
538
472
  function matchesText(filter, actual) {
539
473
  if (filter === void 0) return true;
@@ -542,7 +476,7 @@ function matchesText(filter, actual) {
542
476
  return structuralEquals(filter, actual, new CaptureScope());
543
477
  }
544
478
  //#endregion
545
- //#region src/integrations/anthropic/anthropic.ts
479
+ //#region src/seams/anthropic/anthropic.ts
546
480
  const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
547
481
  function matchesFilter(body, filter) {
548
482
  if (!matchesText(filter.model, body?.model ?? "")) return false;
@@ -633,7 +567,7 @@ const anthropic = {
633
567
  }
634
568
  };
635
569
  //#endregion
636
- //#region src/specification/contracts/http.ts
570
+ //#region src/model/contracts/http.ts
637
571
  function wrapJson(data) {
638
572
  return {
639
573
  status: 200,
@@ -641,6 +575,18 @@ function wrapJson(data) {
641
575
  };
642
576
  }
643
577
  const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
578
+ const STREAM_CONTENT_TYPE = "application/octet-stream";
579
+ const SSE_CONTENT_TYPE = "text/event-stream";
580
+ /** The wire form of one event — the frame an `EventSource` parses. */
581
+ function frameEvent(event) {
582
+ const lines = [];
583
+ if (event.event !== void 0) lines.push(`event: ${event.event}`);
584
+ if (event.id !== void 0) lines.push(`id: ${event.id}`);
585
+ if (event.retry !== void 0) lines.push(`retry: ${event.retry}`);
586
+ const payload = typeof event.data === "string" ? event.data : JSON.stringify(event.data);
587
+ for (const line of payload.split("\n")) lines.push(`data: ${line}`);
588
+ return `${lines.join("\n")}\n\n`;
589
+ }
644
590
  function matchesBody(body, expected) {
645
591
  if (typeof expected === "string") return (typeof body === "string" ? body : JSON.stringify(body ?? "")).includes(expected);
646
592
  if (expected instanceof RegExp) {
@@ -750,6 +696,52 @@ const http = {
750
696
  transport: "network-error"
751
697
  };
752
698
  },
699
+ /**
700
+ * Response: a body that arrives in PIECES, written in order.
701
+ *
702
+ * A subject that reads a response as it lands — a token-by-token render, a
703
+ * progress bar, a reconnect on a half-read body — is specified by the
704
+ * chunks and their order, which a single serialised body cannot state.
705
+ * Honoured by all three engines: msw's server, msw's worker, and the
706
+ * `node:http` stub the website and mobile facets serve from.
707
+ *
708
+ * @example
709
+ * http.stream(['Hel', 'lo, ', 'world'], { contentType: 'text/plain', delay: 10 })
710
+ */
711
+ stream(chunks, init) {
712
+ return {
713
+ status: init?.status ?? 200,
714
+ body: {
715
+ chunks: [...chunks],
716
+ contentType: init?.contentType ?? STREAM_CONTENT_TYPE,
717
+ delayBetweenChunks: init?.delay ?? 0,
718
+ kind: STREAM_BODY
719
+ },
720
+ headers: init?.headers
721
+ };
722
+ },
723
+ /**
724
+ * Response: a server-sent event stream — one chunk per event, framed the
725
+ * way an `EventSource` reads them, served as `text/event-stream`.
726
+ *
727
+ * @example
728
+ * http.sse([{ data: { token: 'Hel' } }, { data: { token: 'lo' } }, { event: 'done', data: '' }])
729
+ */
730
+ sse(events, init) {
731
+ return {
732
+ status: init?.status ?? 200,
733
+ body: {
734
+ chunks: events.map(frameEvent),
735
+ contentType: SSE_CONTENT_TYPE,
736
+ delayBetweenChunks: init?.delay ?? 0,
737
+ kind: STREAM_BODY
738
+ },
739
+ headers: {
740
+ "cache-control": "no-cache",
741
+ ...init?.headers
742
+ }
743
+ };
744
+ },
753
745
  /** Response: a text body, served as `text/plain` (200 by default). */
754
746
  text(body, init) {
755
747
  return {
@@ -764,7 +756,7 @@ const http = {
764
756
  }
765
757
  };
766
758
  //#endregion
767
- //#region src/integrations/openai/openai.ts
759
+ //#region src/seams/openai/openai.ts
768
760
  const OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions";
769
761
  const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
770
762
  function matchesChatFilter(body, filter) {
@@ -920,55 +912,671 @@ const openai = {
920
912
  };
921
913
  }
922
914
  };
923
- //#endregion
924
- //#region src/vitest/mock-of.ts
915
+ //#endregion
916
+ //#region src/model/doubles/mock-of.ts
917
+ function mockOf(options) {
918
+ return options?.deep === false ? mock() : mockDeep();
919
+ }
920
+ //#endregion
921
+ //#region src/model/clock/clock.ts
922
+ /** The instant `iso` names, or a refusal that says what an instant looks like. */
923
+ function instantOf(method, iso) {
924
+ const at = new Date(iso);
925
+ if (Number.isNaN(at.getTime())) throw new TypeError(`clock.${method}(): "${iso}" is not an instant — pass an ISO-8601 string (e.g. '2026-03-04T09:30:00Z').`);
926
+ return at;
927
+ }
928
+ /**
929
+ * One clock per test — a second one taken inside the first is refused rather
930
+ * than nested.
931
+ *
932
+ * Disposing gives the REAL clock back, which is the only thing vitest's fake
933
+ * timers can restore: the instant, the `toFake` set and the queue a previous
934
+ * scope held are not readable, so an inner scope that ended would silently end
935
+ * the outer one too. A refusal says which of the two the test meant.
936
+ */
937
+ function take(method, options) {
938
+ if (vi.isFakeTimers()) throw new Error(`clock.${method}(): a clock is already pinned — one scope per test. End the first scope before taking another, or state the instant on the chain (\`.clock(iso)\`) rather than around it.`);
939
+ vi.useFakeTimers(options);
940
+ return { [Symbol.dispose]: () => {
941
+ vi.useRealTimers();
942
+ } };
943
+ }
944
+ const clock = {
945
+ /**
946
+ * Move the pinned clock forward by `ms` — and, under {@link clock.run},
947
+ * fire every callback that came due, awaiting what each of them started.
948
+ *
949
+ * Only a pinned clock moves: called with no clock taken, it says so rather
950
+ * than silently doing nothing to the real one.
951
+ */
952
+ async advance(ms) {
953
+ if (!vi.isFakeTimers()) throw new Error("clock.advance(): no clock is pinned — take one first with `using _ = clock.at('<iso>')` (the calendar) or `using _ = clock.run()` (the scheduler).");
954
+ await vi.advanceTimersByTimeAsync(ms);
955
+ },
956
+ /**
957
+ * Pin `Date` at `iso` for the current scope. `Date` ONLY: the scheduler
958
+ * stays real, so a subject that awaits, renders or polls keeps running.
959
+ *
960
+ * @example
961
+ * using _ = clock.at('2026-03-04T09:30:00Z');
962
+ * expect(stampedAt()).toBe('2026-03-04T09:30:00.000Z');
963
+ */
964
+ at(iso) {
965
+ return take("at", {
966
+ now: instantOf("at", iso),
967
+ toFake: ["Date"]
968
+ });
969
+ },
970
+ /**
971
+ * Take the scheduler as well as the calendar: `setTimeout`, `setInterval`
972
+ * and their friends queue instead of firing, and {@link clock.advance} is
973
+ * what makes them due. For a subject whose behaviour IS elapsed time — a
974
+ * debounce, a retry backoff, a poll — where waiting for real would be the
975
+ * arbitrary sleep J2 forbids.
976
+ *
977
+ * @example
978
+ * using _ = clock.run('2026-03-04T09:30:00Z');
979
+ * const pending = retrying(failsTwice);
980
+ * await clock.advance(5_000);
981
+ * await expect(pending).resolves.toBe('ok');
982
+ */
983
+ run(iso) {
984
+ return take("run", iso === void 0 ? {} : { now: instantOf("run", iso) });
985
+ }
986
+ };
987
+ //#endregion
988
+ //#region src/model/assertions/required.ts
989
+ /**
990
+ * The value, or a failure that says what was missing and why it mattered.
991
+ *
992
+ * A test reaching into a captured structure — a row, a header, the third
993
+ * element of a list — meets `T | undefined` and has to do something about it.
994
+ * The two things it usually does are both wrong: `!` erases the question, and
995
+ * an `if (!x) throw new Error('missing')` re-states the same three lines in
996
+ * every repository (the estate had two hand-written copies of exactly this).
997
+ * `required` is that gesture with the reason attached, so the failure reads as
998
+ * a sentence rather than as a `TypeError` two frames further down.
999
+ *
1000
+ * @example
1001
+ * const id = required(result.response.body.id, 'the creation reply carries the new id');
1002
+ */
1003
+ function required(value, why) {
1004
+ if (value === null || value === void 0) throw new Error(`required(): ${why} — got ${value === null ? "null" : "undefined"}. State it in the Given, or assert on what the subject actually produced.`);
1005
+ return value;
1006
+ }
1007
+ //#endregion
1008
+ //#region src/model/assertions/wait-until.ts
1009
+ const DEFAULT_TIMEOUT_MS = 5e3;
1010
+ const DEFAULT_INTERVAL_MS = 50;
1011
+ /**
1012
+ * Wait until a condition holds — the sanctioned answer to "the thing I am
1013
+ * specifying is not ready yet".
1014
+ *
1015
+ * A spec that sleeps for a guessed duration is a spec that is slow on a fast
1016
+ * machine and flaky on a slow one, which is why J2 refuses `setTimeout` under
1017
+ * `specs/`. Every facet that drives something already waits on a CONDITION —
1018
+ * a served page, a banner on stdout, an element the visitor can see — and this
1019
+ * is that primitive where no facet owns the waiting: a background write to
1020
+ * land, a queue to drain, a file to appear.
1021
+ *
1022
+ * The failure names the condition and how long it was given, so a timeout is a
1023
+ * sentence rather than "expected true, got false".
1024
+ *
1025
+ * The budget is measured on `performance.now()`, not on `Date` — `clock.at()`
1026
+ * freezes the calendar, and a deadline read off a frozen calendar never
1027
+ * arrives: the poll would run until vitest killed the test with a sentence
1028
+ * about the file rather than about the condition.
1029
+ *
1030
+ * Not for a subject under `clock.run()`: there the scheduler is the test's, and
1031
+ * `clock.advance(ms)` is what makes time pass.
1032
+ *
1033
+ * @example
1034
+ * await waitUntil(() => outbox.length === 1, { why: 'the job wrote its one message' });
1035
+ */
1036
+ async function waitUntil(predicate, options = {}) {
1037
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
1038
+ const interval = options.interval ?? DEFAULT_INTERVAL_MS;
1039
+ const deadline = performance.now() + timeout;
1040
+ for (;;) {
1041
+ if (await predicate()) return;
1042
+ if (performance.now() >= deadline) throw new Error(`waitUntil(): ${options.why ?? "the condition"} did not hold within ${timeout}ms. Raise the budget if the wait is legitimate, or wait on what actually changes.`);
1043
+ await pause(interval);
1044
+ }
1045
+ }
1046
+ //#endregion
1047
+ //#region src/model/chain/caller.ts
1048
+ /** Sibling module tests are callers, not internals (CONVENTIONS I2). */
1049
+ const TEST_FILE = /\.test\.[cm]?[jt]s$/u;
1050
+ /** Where this very module sits inside the framework's SOURCE tree. */
1051
+ const SOURCE_LOCATION = join("model", "chain");
1052
+ /** The real path of `path`, or `path` itself when it cannot be resolved. */
1053
+ function realPath(path) {
1054
+ try {
1055
+ return realpathSync(path);
1056
+ } catch {
1057
+ return path;
1058
+ }
1059
+ }
1060
+ /**
1061
+ * Directory of the running framework module itself. From the built package
1062
+ * this is `<package root>/dist`, whatever the resolution path — including a
1063
+ * `file:` link, where the real path carries neither `node_modules` nor
1064
+ * `/src/…` and would otherwise be mistaken for a caller frame.
1065
+ */
1066
+ const FRAMEWORK_DIR = realPath(import.meta.dirname);
1067
+ /**
1068
+ * The directory holding the framework's OWN modules: `<package>/dist` for a
1069
+ * built install (everything is bundled there), `<repo>/src` when the framework
1070
+ * runs from source — this module's known location inside that tree is what
1071
+ * tells the two apart.
1072
+ */
1073
+ const FRAMEWORK_TREE = FRAMEWORK_DIR.endsWith(`${sep}${SOURCE_LOCATION}`) ? resolve(FRAMEWORK_DIR, "../..") : FRAMEWORK_DIR;
1074
+ /** Is `path` inside `directory` (strictly — a directory is not inside itself)? */
1075
+ function isInside(directory, path) {
1076
+ const rest = relative(directory, path);
1077
+ return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
1078
+ }
1079
+ /**
1080
+ * Is this stack frame a module of the framework itself, rather than of the code
1081
+ * calling it?
1082
+ *
1083
+ * Answered by IDENTITY — the frame is inside the framework's own directory —
1084
+ * and never by substring: a consumer's own `src/model/` is not this package's,
1085
+ * and reading it as one would skip the consumer's files as internals and
1086
+ * anchor fixture resolution on the wrong directory.
1087
+ *
1088
+ * @internal
1089
+ */
1090
+ function isFrameworkFrame(filePath, frameworkTree = FRAMEWORK_TREE) {
1091
+ if (TEST_FILE.test(filePath)) return false;
1092
+ return isInside(realPath(frameworkTree), realPath(filePath));
1093
+ }
1094
+ /**
1095
+ * Detect the directory of the first stack frame that lives outside this
1096
+ * package. Used to anchor fixture resolution (`_seeds/`, `_requests/`,
1097
+ * `_expected/`, …) on the calling test / specification file.
1098
+ *
1099
+ * @internal
1100
+ */
1101
+ function getCallerDir() {
1102
+ const { stack } = /* @__PURE__ */ new Error("caller detection");
1103
+ if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
1104
+ const lines = stack.split("\n");
1105
+ for (const line of lines) {
1106
+ const match = /at\s+(?:.*?\()?(?:file:\/\/)?(?<filePath>[^:)]+):\d+:\d+/u.exec(line);
1107
+ if (!match?.groups?.filePath) continue;
1108
+ const { filePath } = match.groups;
1109
+ if (filePath.includes("node_modules")) continue;
1110
+ if (isFrameworkFrame(filePath)) continue;
1111
+ return resolve(filePath, "..");
1112
+ }
1113
+ return process.cwd();
1114
+ }
1115
+ //#endregion
1116
+ //#region src/model/chain/docker-reader.ts
1117
+ /**
1118
+ * Build the `docker(containerId)` reader handed out by `specification.api()`
1119
+ * and `specification.cli()` — a lazy accessor over `docker inspect`.
1120
+ */
1121
+ function createDockerReader(testDir) {
1122
+ return (containerId) => {
1123
+ try {
1124
+ const inspect = inspectContainer(containerId);
1125
+ return new ContainerAccessor(containerId, inspect, testDir);
1126
+ } catch {
1127
+ return new ContainerAccessor(null, null, testDir);
1128
+ }
1129
+ };
1130
+ }
1131
+ //#endregion
1132
+ //#region src/model/chain/binding.ts
1133
+ /**
1134
+ * The case conversions a services-record key goes through.
1135
+ *
1136
+ * A record key is written in the natural TypeScript style (`analyticsDb`),
1137
+ * while the two vocabularies it feeds are not: a service DIRECTORY under
1138
+ * `docker/` is kebab-case (`analytics-db`), and an injected environment
1139
+ * variable is CONSTANT_CASE (`ANALYTICS_DB_URL`). These two helpers bridge
1140
+ * them deterministically, so the record key stays the single source of truth
1141
+ * and no handle carries a second name of its own.
1142
+ */
1143
+ /**
1144
+ * Convert a record key to its kebab-case form: camelCase boundaries become
1145
+ * `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
1146
+ */
1147
+ function toKebabCase(key) {
1148
+ return key.replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, "$<lower>-$<upper>").toLowerCase();
1149
+ }
1150
+ /**
1151
+ * Convert a record key to CONSTANT_CASE for env injection: insert `_` at
1152
+ * camelCase boundaries, map any remaining non-alphanumeric run to `_`, then
1153
+ * uppercase. `analyticsDb` → `ANALYTICS_DB`, `db-main` → `DB_MAIN`, `db` → `DB`.
1154
+ */
1155
+ function toConstantCase(key) {
1156
+ return key.replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, "$<lower>_$<upper>").replaceAll(/[^A-Za-z0-9]/gu, "_").toUpperCase();
1157
+ }
1158
+ //#endregion
1159
+ //#region src/model/chain/orchestrator.ts
1160
+ /** Where a service reads its init script from — `<root>/docker/<service>/`. */
1161
+ const DOCKER_DIR = "docker";
1162
+ /**
1163
+ * The infrastructure a specification declares, started and stopped as a unit.
1164
+ *
1165
+ * Every declared handle becomes a real thing: a container through
1166
+ * testcontainers, or an embedded service (SQLite) that needs no container. The
1167
+ * record KEY is the only name in play — it is the spec's database vocabulary,
1168
+ * the name the startup report prints, and, kebab-cased, the directory the
1169
+ * service reads its init script from.
1170
+ *
1171
+ * It is internal wiring: the constructors drive it, and no consumer names it.
1172
+ */
1173
+ var Orchestrator = class {
1174
+ services;
1175
+ root;
1176
+ subject;
1177
+ running = [];
1178
+ started = false;
1179
+ constructor(options) {
1180
+ this.services = options.services;
1181
+ this.root = options.root;
1182
+ this.subject = options.subject ?? { type: "in-process" };
1183
+ }
1184
+ /**
1185
+ * Start every declared service.
1186
+ * Phase 1: start all containers in parallel (the slow part).
1187
+ * Phase 2: wire connections, healthcheck, and init sequentially (fast).
1188
+ */
1189
+ async start() {
1190
+ if (this.started) return;
1191
+ const dockerDir = resolve(this.root, DOCKER_DIR);
1192
+ for (const [key, handle] of Object.entries(this.services)) handle.serviceName = toKebabCase(key);
1193
+ const containerServices = [];
1194
+ const embeddedServices = [];
1195
+ for (const handle of Object.values(this.services)) {
1196
+ if (handle.defaultPort === 0) {
1197
+ embeddedServices.push(handle);
1198
+ continue;
1199
+ }
1200
+ const container = getContainerIntegrations().createContainer({
1201
+ image: handle.defaultImage,
1202
+ port: handle.defaultPort,
1203
+ env: { ...handle.environment }
1204
+ });
1205
+ containerServices.push({
1206
+ container,
1207
+ handle
1208
+ });
1209
+ }
1210
+ await Promise.all([...containerServices.map(async ({ container }) => {
1211
+ await container.start();
1212
+ }), ...embeddedServices.map(async (handle) => {
1213
+ await handle.initialize(dockerDir, this.root);
1214
+ handle.started = true;
1215
+ this.running.push({
1216
+ handle,
1217
+ container: null
1218
+ });
1219
+ })]);
1220
+ const reports = [];
1221
+ for (const { container, handle } of containerServices) {
1222
+ const serviceStartTime = Date.now();
1223
+ try {
1224
+ const host = container.getHost();
1225
+ const port = container.getMappedPort(handle.defaultPort);
1226
+ handle.connectionString = handle.buildConnectionString(host, port);
1227
+ await handle.healthcheck();
1228
+ await handle.initialize(dockerDir, this.root);
1229
+ handle.started = true;
1230
+ reports.push({
1231
+ name: handle.serviceName ?? handle.type,
1232
+ type: handle.type,
1233
+ connectionString: handle.connectionString,
1234
+ durationMs: Date.now() - serviceStartTime
1235
+ });
1236
+ this.running.push({
1237
+ handle,
1238
+ container
1239
+ });
1240
+ } catch (error) {
1241
+ let logs = "";
1242
+ try {
1243
+ logs = await container.getLogs();
1244
+ } catch {}
1245
+ try {
1246
+ await container.stop();
1247
+ } catch {}
1248
+ reports.push({
1249
+ name: handle.serviceName ?? handle.type,
1250
+ type: handle.type,
1251
+ durationMs: Date.now() - serviceStartTime,
1252
+ error: error.message,
1253
+ logs
1254
+ });
1255
+ const output = formatStartupReport(reports, this.subject);
1256
+ console.error(output);
1257
+ throw error;
1258
+ }
1259
+ }
1260
+ this.started = true;
1261
+ const output = formatStartupReport(reports, this.subject);
1262
+ console.log(output);
1263
+ }
1264
+ /** Stop every container this stack started. */
1265
+ async stop() {
1266
+ for (const { container } of this.running) if (container) await container.stop();
1267
+ this.running = [];
1268
+ this.started = false;
1269
+ }
1270
+ /** The default database — the first declared handle that is one. */
1271
+ getDatabase() {
1272
+ for (const handle of Object.values(this.services)) {
1273
+ const adapter = handle.createDatabaseAdapter();
1274
+ if (adapter) return adapter;
1275
+ }
1276
+ return null;
1277
+ }
1278
+ /** Every database service, keyed by its record key. */
1279
+ getDatabases() {
1280
+ const map = /* @__PURE__ */ new Map();
1281
+ for (const [key, handle] of Object.entries(this.services)) {
1282
+ const adapter = handle.createDatabaseAdapter();
1283
+ if (adapter) map.set(key, adapter);
1284
+ }
1285
+ return map;
1286
+ }
1287
+ };
1288
+ //#endregion
1289
+ //#region src/model/chain/process.ts
1290
+ /**
1291
+ * An external process, declared like any other service.
1292
+ *
1293
+ * One shape for every one of them: a site's dev server, an API behind the
1294
+ * site, the bundler a simulator loads from. Without it each is declared
1295
+ * somewhere else — a facet option, a document's `serve:` entry, a `beforeAll`
1296
+ * spawning a child and an `afterAll` that sometimes forgets to kill it — and
1297
+ * the lifetime belongs to whoever remembered. Here it is the framework's.
1298
+ *
1299
+ * It sits in a `services` record beside `postgres()`, `redis()` and
1300
+ * `sqlite()`, so it starts after them and can be handed their connection
1301
+ * strings through `env`, and it is stopped with the specification.
1302
+ */
1303
+ var ProcessHandle = class {
1304
+ /** Embedded: nothing to pull, nothing to run in a container. */
1305
+ defaultImage = "";
1306
+ defaultPort = 0;
1307
+ environment = {};
1308
+ type = "process";
1309
+ serviceName = null;
1310
+ connectionString = "";
1311
+ started = false;
1312
+ adapter = null;
1313
+ options;
1314
+ constructor(options) {
1315
+ this.options = options;
1316
+ }
1317
+ buildConnectionString(host, port) {
1318
+ return `http://${host}:${port}`;
1319
+ }
1320
+ createDatabaseAdapter() {
1321
+ return null;
1322
+ }
1323
+ async healthcheck() {
1324
+ await Promise.resolve();
1325
+ }
1326
+ /**
1327
+ * A process is not a container, so the orchestrator never starts it: the
1328
+ * facet does, once its siblings are up and their connection strings are
1329
+ * readable ({@link startProcessServices}).
1330
+ */
1331
+ async initialize() {
1332
+ await Promise.resolve();
1333
+ }
1334
+ /**
1335
+ * Isolation is the RUN's, not the worker's — and a run here is one
1336
+ * EVALUATION of the specification module. Under vitest's default
1337
+ * isolation that is once per test file, so two files that import the same
1338
+ * specification get a child each, with the id the facet minted for it.
1339
+ * Nothing is acquired or reset between tests: the id is what tells two
1340
+ * runs apart, never a label a spec samples.
1341
+ */
1342
+ isolation() {
1343
+ return {
1344
+ acquire: async () => {
1345
+ await Promise.resolve();
1346
+ },
1347
+ release: async () => {
1348
+ await Promise.resolve();
1349
+ },
1350
+ reset: async () => {
1351
+ await Promise.resolve();
1352
+ }
1353
+ };
1354
+ }
1355
+ async reset() {
1356
+ await Promise.resolve();
1357
+ }
1358
+ /**
1359
+ * Spawn it and wait until it answers. `services` is every handle declared
1360
+ * beside this one, already started — what an `env` function reads.
1361
+ *
1362
+ * @internal
1363
+ */
1364
+ async spawnWith(root, services, runId) {
1365
+ const stated = typeof this.options.env === "function" ? this.options.env(services) : this.options.env;
1366
+ this.adapter = new ServeAdapter(this.options, root, "process", {
1367
+ ...stated,
1368
+ TEST_RUN_ID: runId
1369
+ });
1370
+ this.connectionString = await this.adapter.start();
1371
+ this.started = true;
1372
+ }
1373
+ /** Terminate the process group. Idempotent. */
1374
+ async shutdown() {
1375
+ await this.adapter?.stop();
1376
+ this.adapter = null;
1377
+ this.started = false;
1378
+ }
1379
+ /** What it was declared as — what a facet reads to start it in a slot of its own. */
1380
+ get spec() {
1381
+ return this.options;
1382
+ }
1383
+ /** The base URL it came up on — known only once it is started. */
1384
+ get url() {
1385
+ if (!this.started) throw new Error("process(): the URL is known once the process is ready — read it from the started services record, never at declaration time.");
1386
+ return this.connectionString;
1387
+ }
1388
+ };
925
1389
  /**
926
- * Create a deep mock proxy for a given type.
927
- * Wraps `vitest-mock-extended`'s `mockDeep` for convenient port mocking.
1390
+ * Declare an external process the framework owns for the life of the
1391
+ * specification.
1392
+ *
1393
+ * @example
1394
+ * const { website, cleanup } = await specification.website({
1395
+ * services: { api: process({ command: 'bin/server web', ready: '/health' }) },
1396
+ * server: ({ api }) => process({
1397
+ * command: 'next dev',
1398
+ * env: { NEXT_PUBLIC_API_URL: api.connectionString },
1399
+ * }),
1400
+ * });
928
1401
  */
929
- const mockOf = mockDeep;
1402
+ function processService(options) {
1403
+ return new ProcessHandle(options);
1404
+ }
930
1405
  //#endregion
931
- //#region src/vitest/mock-of-date.ts
1406
+ //#region src/model/chain/services.ts
1407
+ function getWorkerId() {
1408
+ return process.env.VITEST_POOL_ID ?? "0";
1409
+ }
1410
+ async function acquireIsolation(services) {
1411
+ const workerId = getWorkerId();
1412
+ for (const service of Object.values(services)) await service.isolation().acquire(workerId);
1413
+ }
1414
+ async function releaseIsolation(services) {
1415
+ for (const service of Object.values(services)) await service.isolation().release();
1416
+ }
1417
+ function declaredDatabaseKeys(services) {
1418
+ return Object.entries(services).filter(([, service]) => service.createDatabaseAdapter() !== null).map(([key]) => key);
1419
+ }
932
1420
  /**
933
- * Freeze or reset the global Date for deterministic time-dependent tests.
934
- * Wraps the `mockdate` package.
1421
+ * A process is not a container: the orchestrator never sees one, and the
1422
+ * facet starts it AFTER the rest, so an `env` function reads connection
1423
+ * strings that are already there.
935
1424
  */
936
- const mockOfDate = MockDatePackage;
937
- //#endregion
938
- //#region src/integrations/hono/hono.adapter.ts
1425
+ function splitProcesses(services) {
1426
+ const infrastructure = {};
1427
+ const processes = [];
1428
+ for (const [key, handle] of Object.entries(services)) if (handle instanceof ProcessHandle) processes.push([key, handle]);
1429
+ else infrastructure[key] = handle;
1430
+ return {
1431
+ infrastructure,
1432
+ processes
1433
+ };
1434
+ }
939
1435
  /**
940
- * Server adapter that dispatches requests in-process through a Hono app instance.
941
- * Used by the `integration()` specification runner -- no network overhead.
1436
+ * Start every declared process, in declaration order, once the infrastructure
1437
+ * is up — so `env: ({ db }) => …` reads a connection string that exists. Each
1438
+ * carries the run's id, minted here: a label a spec would otherwise sample
1439
+ * from `Date.now()` and put under an oracle (CONVENTIONS D16, P7).
1440
+ *
1441
+ * @internal
942
1442
  */
943
- var HonoAdapter = class {
944
- app;
945
- constructor(app) {
946
- this.app = app;
1443
+ async function startProcessServices(services, root, runId) {
1444
+ const { processes } = splitProcesses(services);
1445
+ const started = [];
1446
+ const stop = async () => {
1447
+ for (const handle of started.toReversed()) await handle.shutdown();
1448
+ started.length = 0;
1449
+ };
1450
+ for (const [, handle] of processes) try {
1451
+ await handle.spawnWith(root, services, runId);
1452
+ started.push(handle);
1453
+ } catch (error) {
1454
+ await stop();
1455
+ throw error;
947
1456
  }
948
- async request(method, path, body, headers) {
949
- const init = {
950
- method,
951
- headers: {
952
- "Content-Type": "application/json",
953
- ...headers
954
- }
955
- };
956
- if (body !== void 0) init.body = typeof body === "string" ? body : JSON.stringify(body);
957
- const response = await this.app.request(path, init);
958
- const responseBody = await response.json().catch(() => null);
959
- const responseHeaders = {};
960
- response.headers.forEach((value, key) => {
961
- responseHeaders[key] = value;
962
- });
963
- return {
964
- status: response.status,
965
- body: responseBody,
966
- headers: responseHeaders
967
- };
1457
+ return stop;
1458
+ }
1459
+ /** Start a services record via testcontainers and acquire worker isolation. */
1460
+ async function startServices(services, root, subject) {
1461
+ const { infrastructure } = splitProcesses(services);
1462
+ const orchestrator = new Orchestrator({
1463
+ root,
1464
+ services: infrastructure,
1465
+ subject
1466
+ });
1467
+ await orchestrator.start();
1468
+ await acquireIsolation(infrastructure);
1469
+ const runId = randomUUID();
1470
+ const stopProcesses = await startProcessServices(services, root, runId);
1471
+ const databases = orchestrator.getDatabases();
1472
+ return {
1473
+ database: orchestrator.getDatabase() ?? void 0,
1474
+ databases: databases.size > 0 ? databases : void 0,
1475
+ orchestrator,
1476
+ runId,
1477
+ stopProcesses
1478
+ };
1479
+ }
1480
+ //#endregion
1481
+ //#region src/model/http-files/http-file.ts
1482
+ function splitSections(content) {
1483
+ const lines = content.split(/\r?\n/u);
1484
+ let index = 0;
1485
+ while (lines[index]?.trim() === "") index++;
1486
+ const startLine = lines[index] ?? "";
1487
+ index++;
1488
+ const headerLines = [];
1489
+ let header = lines[index];
1490
+ while (header !== void 0 && header.trim() !== "") {
1491
+ headerLines.push(header);
1492
+ index++;
1493
+ header = lines[index];
1494
+ }
1495
+ return {
1496
+ body: lines.slice(index + 1).join("\n").trim(),
1497
+ headerLines,
1498
+ startLine
1499
+ };
1500
+ }
1501
+ function parseHeaders(headerLines, fileName) {
1502
+ const headers = {};
1503
+ for (const line of headerLines) {
1504
+ const separator = line.indexOf(":");
1505
+ if (separator === -1) throw new Error(`${fileName}: invalid header line "${line}" (expected "Name: value")`);
1506
+ headers[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
1507
+ }
1508
+ return headers;
1509
+ }
1510
+ /** Parse a `_requests/*.http` file. First line must be `METHOD /path`. */
1511
+ function parseRequestFile(content, fileName) {
1512
+ const { body, headerLines, startLine } = splitSections(content);
1513
+ const start = /^(?<method>[A-Z]+)\s+(?<path>\S+)$/u.exec(startLine.trim());
1514
+ const method = start?.groups?.method;
1515
+ const path = start?.groups?.path;
1516
+ if (method === void 0 || path === void 0) throw new Error(`${fileName}: first line must be "METHOD /path" (e.g. "POST /users"), got "${startLine}"`);
1517
+ return {
1518
+ body: body.length > 0 ? body : void 0,
1519
+ headers: parseHeaders(headerLines, fileName),
1520
+ method,
1521
+ path
1522
+ };
1523
+ }
1524
+ /** Parse a response `.http` file. First line must be `HTTP/1.1 <status>`. */
1525
+ function parseResponseFile(content, fileName) {
1526
+ const { body, headerLines, startLine } = splitSections(content);
1527
+ const status = /^HTTP\/1\.1\s+(?<status>\S+)(?:\s+.*)?$/u.exec(startLine.trim())?.groups?.status;
1528
+ if (status === void 0) throw new Error(`${fileName}: first line must be "HTTP/1.1 <status>" (e.g. "HTTP/1.1 200 OK"), got "${startLine}"`);
1529
+ let parsedBody;
1530
+ if (body.length > 0) try {
1531
+ parsedBody = JSON.parse(body);
1532
+ } catch {
1533
+ parsedBody = body;
968
1534
  }
1535
+ return {
1536
+ body: parsedBody,
1537
+ hasBody: body.length > 0,
1538
+ headers: parseHeaders(headerLines, fileName),
1539
+ status
1540
+ };
1541
+ }
1542
+ const STATUS_TEXT = {
1543
+ 200: "OK",
1544
+ 201: "Created",
1545
+ 202: "Accepted",
1546
+ 204: "No Content",
1547
+ 301: "Moved Permanently",
1548
+ 302: "Found",
1549
+ 304: "Not Modified",
1550
+ 400: "Bad Request",
1551
+ 401: "Unauthorized",
1552
+ 403: "Forbidden",
1553
+ 404: "Not Found",
1554
+ 409: "Conflict",
1555
+ 422: "Unprocessable Entity",
1556
+ 429: "Too Many Requests",
1557
+ 500: "Internal Server Error",
1558
+ 502: "Bad Gateway",
1559
+ 503: "Service Unavailable"
969
1560
  };
1561
+ /** Serialize a response fixture (used by update mode). */
1562
+ function serializeResponseFile(response) {
1563
+ const statusNumber = Number(response.status);
1564
+ const statusText = STATUS_TEXT[statusNumber];
1565
+ const lines = [`HTTP/1.1 ${response.status}${statusText ? ` ${statusText}` : ""}`];
1566
+ for (const [name, value] of Object.entries(response.headers)) lines.push(`${name}: ${value}`);
1567
+ if (response.hasBody) lines.push("", typeof response.body === "string" && !isJsonLike(response.body) ? response.body : JSON.stringify(response.body, null, 4));
1568
+ return `${lines.join("\n")}\n`;
1569
+ }
1570
+ function isJsonLike(text) {
1571
+ try {
1572
+ JSON.parse(text);
1573
+ return true;
1574
+ } catch {
1575
+ return false;
1576
+ }
1577
+ }
970
1578
  //#endregion
971
- //#region src/specification/facets/_common/result/response.ts
1579
+ //#region src/model/result/response.ts
972
1580
  /**
973
1581
  * Read-only accessor for an HTTP response.
974
1582
  *
@@ -997,7 +1605,7 @@ var ResponseAccessor = class {
997
1605
  }
998
1606
  };
999
1607
  //#endregion
1000
- //#region src/specification/facets/_common/result/text-assertions.ts
1608
+ //#region src/model/result/text-assertions.ts
1001
1609
  /** The fixture name carries its extension — the name IS the file. */
1002
1610
  function requireExtension(name, subject) {
1003
1611
  if (!/\.[A-Za-z0-9]+$/u.test(name)) throw new Error(`toMatch("${name}"): the extension is part of the name and is required for ${subject} subjects (e.g. "help.txt").`);
@@ -1026,158 +1634,60 @@ function textIsEmpty(accessor) {
1026
1634
  const pass = content === "";
1027
1635
  return {
1028
1636
  message: () => pass ? `expected ${accessor.streamName} not to be empty` : `Expected ${accessor.streamName} to be empty, but it contains:\n${content}`,
1029
- pass
1030
- };
1031
- }
1032
- /** `expect(stream).toContain('…')` — the scalpel, never the default (D11). */
1033
- function textContains(accessor, expected) {
1034
- const actual = accessor.comparableText;
1035
- const pass = actual.includes(expected);
1036
- return {
1037
- message: () => pass ? `expected ${accessor.streamName} not to contain ${JSON.stringify(expected)}` : `${accessor.streamName} does not contain expected substring.\n expected to contain: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual.length > 500 ? `${actual.slice(0, 500)}…` : actual)}`,
1038
- pass
1039
- };
1040
- }
1041
- /**
1042
- * `toMatch` and `toContain` on a subject that is NOT an accessor.
1043
- *
1044
- * Both matchers are overridden globally, for every subject in the project, so
1045
- * whatever the framework does not own has to keep meaning what vitest's own
1046
- * matcher meant. Stated once here because the two builds register two different
1047
- * `expect.extend` calls: a copy in the page is how `expect(['ab']).toContain('b')`
1048
- * ends up passing on one side of the seam and failing on the other.
1049
- */
1050
- /** vitest's `toMatch` for a plain string: a substring, or a regular expression. */
1051
- function nativeMatch(received, expected) {
1052
- if (typeof received !== "string") throw new TypeError("toMatch: unsupported subject — expected a stream, json, response, filesystem, or directory accessor, or a string.");
1053
- const pass = expected instanceof RegExp ? expected.test(received) : received.includes(String(expected));
1054
- return {
1055
- message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to match ${String(expected)}`,
1056
- pass
1057
- };
1058
- }
1059
- /** vitest's `toContain` for a plain string (substring) or an iterable (membership). */
1060
- function nativeContain(received, expected) {
1061
- if (typeof received === "string") {
1062
- const pass = received.includes(String(expected));
1063
- return {
1064
- message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
1065
- pass
1066
- };
1067
- }
1068
- if (isIterable(received)) {
1069
- const pass = [...received].includes(expected);
1070
- return {
1071
- message: () => `expected iterable ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
1072
- pass
1073
- };
1074
- }
1075
- throw new TypeError(`toContain: unsupported subject of type ${typeof received} — expected a stream accessor, string, or iterable.`);
1076
- }
1077
- /** Does this value hand out its members? A Set and an array both do; a DOM node does not. */
1078
- function isIterable(value) {
1079
- return value !== null && value !== void 0 && typeof value[Symbol.iterator] === "function";
1080
- }
1081
- //#endregion
1082
- //#region src/specification/http-files/http-file.ts
1083
- function splitSections(content) {
1084
- const lines = content.split(/\r?\n/u);
1085
- let index = 0;
1086
- while (lines[index]?.trim() === "") index++;
1087
- const startLine = lines[index] ?? "";
1088
- index++;
1089
- const headerLines = [];
1090
- let header = lines[index];
1091
- while (header !== void 0 && header.trim() !== "") {
1092
- headerLines.push(header);
1093
- index++;
1094
- header = lines[index];
1095
- }
1096
- return {
1097
- body: lines.slice(index + 1).join("\n").trim(),
1098
- headerLines,
1099
- startLine
1100
- };
1101
- }
1102
- function parseHeaders(headerLines, fileName) {
1103
- const headers = {};
1104
- for (const line of headerLines) {
1105
- const separator = line.indexOf(":");
1106
- if (separator === -1) throw new Error(`${fileName}: invalid header line "${line}" (expected "Name: value")`);
1107
- headers[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
1108
- }
1109
- return headers;
1110
- }
1111
- /** Parse a `_requests/*.http` file. First line must be `METHOD /path`. */
1112
- function parseRequestFile(content, fileName) {
1113
- const { body, headerLines, startLine } = splitSections(content);
1114
- const start = /^(?<method>[A-Z]+)\s+(?<path>\S+)$/u.exec(startLine.trim());
1115
- const method = start?.groups?.method;
1116
- const path = start?.groups?.path;
1117
- if (method === void 0 || path === void 0) throw new Error(`${fileName}: first line must be "METHOD /path" (e.g. "POST /users"), got "${startLine}"`);
1118
- return {
1119
- body: body.length > 0 ? body : void 0,
1120
- headers: parseHeaders(headerLines, fileName),
1121
- method,
1122
- path
1637
+ pass
1123
1638
  };
1124
1639
  }
1125
- /** Parse a response `.http` file. First line must be `HTTP/1.1 <status>`. */
1126
- function parseResponseFile(content, fileName) {
1127
- const { body, headerLines, startLine } = splitSections(content);
1128
- const status = /^HTTP\/1\.1\s+(?<status>\S+)(?:\s+.*)?$/u.exec(startLine.trim())?.groups?.status;
1129
- if (status === void 0) throw new Error(`${fileName}: first line must be "HTTP/1.1 <status>" (e.g. "HTTP/1.1 200 OK"), got "${startLine}"`);
1130
- let parsedBody;
1131
- if (body.length > 0) try {
1132
- parsedBody = JSON.parse(body);
1133
- } catch {
1134
- parsedBody = body;
1135
- }
1640
+ /** `expect(stream).toContain('…')` — the scalpel, never the default (D11). */
1641
+ function textContains(accessor, expected) {
1642
+ const actual = accessor.comparableText;
1643
+ const pass = actual.includes(expected);
1136
1644
  return {
1137
- body: parsedBody,
1138
- hasBody: body.length > 0,
1139
- headers: parseHeaders(headerLines, fileName),
1140
- status
1645
+ message: () => pass ? `expected ${accessor.streamName} not to contain ${JSON.stringify(expected)}` : `${accessor.streamName} does not contain expected substring.\n expected to contain: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual.length > 500 ? `${actual.slice(0, 500)}…` : actual)}`,
1646
+ pass
1141
1647
  };
1142
1648
  }
1143
- const STATUS_TEXT = {
1144
- 200: "OK",
1145
- 201: "Created",
1146
- 202: "Accepted",
1147
- 204: "No Content",
1148
- 301: "Moved Permanently",
1149
- 302: "Found",
1150
- 304: "Not Modified",
1151
- 400: "Bad Request",
1152
- 401: "Unauthorized",
1153
- 403: "Forbidden",
1154
- 404: "Not Found",
1155
- 409: "Conflict",
1156
- 422: "Unprocessable Entity",
1157
- 429: "Too Many Requests",
1158
- 500: "Internal Server Error",
1159
- 502: "Bad Gateway",
1160
- 503: "Service Unavailable"
1161
- };
1162
- /** Serialize a response fixture (used by update mode). */
1163
- function serializeResponseFile(response) {
1164
- const statusNumber = Number(response.status);
1165
- const statusText = STATUS_TEXT[statusNumber];
1166
- const lines = [`HTTP/1.1 ${response.status}${statusText ? ` ${statusText}` : ""}`];
1167
- for (const [name, value] of Object.entries(response.headers)) lines.push(`${name}: ${value}`);
1168
- if (response.hasBody) lines.push("", typeof response.body === "string" && !isJsonLike(response.body) ? response.body : JSON.stringify(response.body, null, 4));
1169
- return `${lines.join("\n")}\n`;
1649
+ /**
1650
+ * `toMatch` and `toContain` on a subject that is NOT an accessor.
1651
+ *
1652
+ * Both matchers are overridden globally, for every subject in the project, so
1653
+ * whatever the framework does not own has to keep meaning what vitest's own
1654
+ * matcher meant. Stated once here because the two builds register two different
1655
+ * `expect.extend` calls: a copy in the page is how `expect(['ab']).toContain('b')`
1656
+ * ends up passing on one side of the seam and failing on the other.
1657
+ */
1658
+ /** vitest's `toMatch` for a plain string: a substring, or a regular expression. */
1659
+ function nativeMatch(received, expected) {
1660
+ if (typeof received !== "string") throw new TypeError("toMatch: unsupported subject — expected a stream, json, response, filesystem, or directory accessor, or a string.");
1661
+ const pass = expected instanceof RegExp ? expected.test(received) : received.includes(String(expected));
1662
+ return {
1663
+ message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to match ${String(expected)}`,
1664
+ pass
1665
+ };
1170
1666
  }
1171
- function isJsonLike(text) {
1172
- try {
1173
- JSON.parse(text);
1174
- return true;
1175
- } catch {
1176
- return false;
1667
+ /** vitest's `toContain` for a plain string (substring) or an iterable (membership). */
1668
+ function nativeContain(received, expected) {
1669
+ if (typeof received === "string") {
1670
+ const pass = received.includes(String(expected));
1671
+ return {
1672
+ message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
1673
+ pass
1674
+ };
1177
1675
  }
1676
+ if (isIterable(received)) {
1677
+ const pass = [...received].includes(expected);
1678
+ return {
1679
+ message: () => `expected iterable ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
1680
+ pass
1681
+ };
1682
+ }
1683
+ throw new TypeError(`toContain: unsupported subject of type ${typeof received} — expected a stream accessor, string, or iterable.`);
1684
+ }
1685
+ /** Does this value hand out its members? A Set and an array both do; a DOM node does not. */
1686
+ function isIterable(value) {
1687
+ return value !== null && value !== void 0 && typeof value[Symbol.iterator] === "function";
1178
1688
  }
1179
1689
  //#endregion
1180
- //#region src/vitest/matchers.ts
1690
+ //#region src/model/goldens/matchers.ts
1181
1691
  /**
1182
1692
  * Vitest custom matchers for `@jterrazz/test` accessors.
1183
1693
  *
@@ -1205,12 +1715,13 @@ function matchStreamFile(accessor, name, frozen) {
1205
1715
  const filePath = resolve(accessor.testDir, GROUND_EXPECTED, name);
1206
1716
  const actual = accessor.comparableText;
1207
1717
  if (shouldUpdateSnapshots() && !frozen) {
1208
- const merged = mergeTextPreservingPlaceholders(existsSync(filePath) ? readFileSync(filePath, "utf8") : null, actual, accessor.captures);
1718
+ const previous = existsSync(filePath) ? readFileSync(filePath, "utf8") : null;
1719
+ const merged = mergeTextPreservingPlaceholders(previous, actual, accessor.captures);
1209
1720
  mkdirSync(dirname(filePath), { recursive: true });
1210
1721
  writeFileSync(filePath, merged);
1211
1722
  return PASS(`updated ${GROUND_EXPECTED}/${name}`);
1212
1723
  }
1213
- if (!existsSync(filePath)) return FAIL(`${accessor.streamName} fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
1724
+ if (!existsSync(filePath)) return FAIL(`${accessor.streamName} fixture "${name}" does not exist at ${filePath}.\n${missingHint(frozen)}`);
1214
1725
  return compareStreamText(accessor, name, readFileSync(filePath, "utf8"));
1215
1726
  }
1216
1727
  function matchJsonFile(accessor, name, frozen) {
@@ -1223,7 +1734,7 @@ function matchJsonFile(accessor, name, frozen) {
1223
1734
  writeFileSync(filePath, formatJson(merged));
1224
1735
  return PASS(`updated ${GROUND_EXPECTED}/${name}`);
1225
1736
  }
1226
- if (!existsSync(filePath)) return FAIL(`JSON fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
1737
+ if (!existsSync(filePath)) return FAIL(`JSON fixture "${name}" does not exist at ${filePath}.\n${missingHint(frozen)}`);
1227
1738
  const expected = JSON.parse(readFileSync(filePath, "utf8"));
1228
1739
  if (structuralEquals(expected, actual, accessor.captures)) return PASS(`expected JSON not to match ${GROUND_EXPECTED}/${name}`);
1229
1740
  return FAIL(formatResponseDiff(name, renderExpected(expected), actual));
@@ -1279,7 +1790,7 @@ function matchResponseFile(accessor, name, frozen) {
1279
1790
  writeFileSync(filePath, serializeResponseFile(updated));
1280
1791
  return PASS(`updated ${GROUND_EXPECTED}/${name}`);
1281
1792
  }
1282
- if (!existsSync(filePath)) return FAIL(`Response fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
1793
+ if (!existsSync(filePath)) return FAIL(`Response fixture "${name}" does not exist at ${filePath}.\n${missingHint(frozen)}`);
1283
1794
  const failure = compareResponse(name, parseResponseFile(readFileSync(filePath, "utf8"), `${GROUND_EXPECTED}/${name}`), accessor, accessor.captures);
1284
1795
  return failure === null ? PASS(`expected response not to match ${GROUND_EXPECTED}/${name}`) : FAIL(failure);
1285
1796
  }
@@ -1302,7 +1813,7 @@ async function matchTreeFile(actualRoot, testDir, name, scope, frozen) {
1302
1813
  }
1303
1814
  return PASS(`updated ${GROUND_EXPECTED}/${name}/`);
1304
1815
  }
1305
- if (!existsSync(fixtureDir)) return FAIL(`Directory fixture "${name}" does not exist at ${fixtureDir}.\n${UPDATE_HINT}`);
1816
+ if (!existsSync(fixtureDir)) return FAIL(`Directory fixture "${name}" does not exist at ${fixtureDir}.\n${missingHint(frozen)}`);
1306
1817
  const diff = await diffDirectories(fixtureDir, actualRoot, { scope });
1307
1818
  if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return PASS(`expected directory not to match ${GROUND_EXPECTED}/${name}/`);
1308
1819
  return FAIL(formatDirectoryDiff(name, diff, "Run with TEST_UPDATE=1 to update the fixture."));
@@ -1347,8 +1858,15 @@ function toContain(received, expected) {
1347
1858
  if (received instanceof TextAccessor) return textContains(received, String(expected));
1348
1859
  return nativeContain(received, expected);
1349
1860
  }
1861
+ /** Is this the shape, before the adapter is asked to build SQL from it? */
1862
+ function isRowsExpectation(expected) {
1863
+ if (expected === null || typeof expected !== "object") return false;
1864
+ const { columns, rows } = expected;
1865
+ return Array.isArray(columns) && Array.isArray(rows);
1866
+ }
1350
1867
  async function toMatchRows(received, expected) {
1351
1868
  if (!(received instanceof TableAccessor)) throw new TypeError("toMatchRows: unsupported subject — expected result.table(...).");
1869
+ if (!isRowsExpectation(expected)) throw new TypeError(`toMatchRows takes { columns, rows } — a column list and one array of cells per row (received ${Array.isArray(expected) ? "an array" : `a ${typeof expected}`}).`);
1352
1870
  const actual = await received.query(expected.columns);
1353
1871
  const pass = actual.length === expected.rows.length && expected.rows.every((row, i) => {
1354
1872
  const actualRow = actual[i];
@@ -1405,7 +1923,40 @@ async function registerMatchers() {
1405
1923
  }
1406
1924
  }
1407
1925
  //#endregion
1408
- //#region src/specification/facets/api/result.ts
1926
+ //#region src/seams/hono/hono.adapter.ts
1927
+ /**
1928
+ * Server adapter that dispatches requests in-process through a Hono app instance.
1929
+ * Used by the `integration()` specification runner -- no network overhead.
1930
+ */
1931
+ var HonoAdapter = class {
1932
+ app;
1933
+ constructor(app) {
1934
+ this.app = app;
1935
+ }
1936
+ async request(method, path, body, headers) {
1937
+ const init = {
1938
+ method,
1939
+ headers: {
1940
+ "Content-Type": "application/json",
1941
+ ...headers
1942
+ }
1943
+ };
1944
+ if (body !== void 0) init.body = typeof body === "string" ? body : JSON.stringify(body);
1945
+ const response = await this.app.request(path, init);
1946
+ const responseBody = await response.json().catch(() => null);
1947
+ const responseHeaders = {};
1948
+ response.headers.forEach((value, key) => {
1949
+ responseHeaders[key] = value;
1950
+ });
1951
+ return {
1952
+ status: response.status,
1953
+ body: responseBody,
1954
+ headers: responseHeaders
1955
+ };
1956
+ }
1957
+ };
1958
+ //#endregion
1959
+ //#region src/facets/api/api.result.ts
1409
1960
  /** Result from an HTTP action (.request(), .get(), .post(), .put(), .delete()). */
1410
1961
  var HttpResult = class extends BaseResult {
1411
1962
  responseData;
@@ -1423,7 +1974,62 @@ var HttpResult = class extends BaseResult {
1423
1974
  }
1424
1975
  };
1425
1976
  //#endregion
1426
- //#region src/specification/facets/mobile/result.ts
1977
+ //#region src/facets/integration/integration.result.ts
1978
+ /**
1979
+ * The result of an in-process call — the integration facet's whole surface.
1980
+ *
1981
+ * Two readings, never both: `value` is what the call RETURNED, `error` is
1982
+ * what it THREW. A module that refuses is specified the same way a module
1983
+ * that answers is, and neither needs a `try`/`catch` in the test — a shape
1984
+ * that costs a refusal spec three times the size of the happy one, and that
1985
+ * quietly passes when nothing throws at all.
1986
+ *
1987
+ * Both are the package's ordinary accessors, so the golden mechanism reaches
1988
+ * them, and `Returned` is what `.call<T>()` returned — `result.value.value` is
1989
+ * that type, not `unknown`, so a one-field reading needs no golden file: `toMatch('<name>.json'|'<name>.txt')`, the `{{token}}` grammar,
1990
+ * `{ frozen }` and `TEST_UPDATE=1` all work here exactly as on an HTTP
1991
+ * response or a command's stdout.
1992
+ */
1993
+ var CallResult = class extends BaseResult {
1994
+ outcome;
1995
+ constructor(options) {
1996
+ super(options);
1997
+ this.outcome = options.outcome;
1998
+ }
1999
+ /**
2000
+ * What the call THREW, as text — the message of an `Error`, the value
2001
+ * itself when something else was thrown, and empty when it returned.
2002
+ *
2003
+ * Empty rather than absent on purpose: `await expect(result.error).toBeEmpty()`
2004
+ * is how a spec says "and it did not refuse" — awaited, because the
2005
+ * matcher answers a promise on every subject it takes, and a dropped one
2006
+ * passes the test while the failure surfaces as an unhandled rejection
2007
+ * (D2 refuses the bare form).
2008
+ */
2009
+ get error() {
2010
+ return new TextAccessor(describeError(this.outcome), "error", this.testDir, { captures: this.captures });
2011
+ }
2012
+ /**
2013
+ * What the call RETURNED. A string reads as a stream (`<name>.txt`),
2014
+ * anything else as JSON (`<name>.json`) — the same two subjects every
2015
+ * other facet's results are built from.
2016
+ *
2017
+ * A call that threw returns the empty value: what it produced is
2018
+ * {@link CallResult.error}.
2019
+ */
2020
+ get value() {
2021
+ const produced = this.outcome.threw ? null : this.outcome.value;
2022
+ return typeof produced === "string" ? new TextAccessor(produced, "value", this.testDir, { captures: this.captures }) : new JsonAccessor(JSON.stringify(produced ?? null), this.testDir, void 0, this.captures);
2023
+ }
2024
+ };
2025
+ /** The thrown thing, as the text a golden compares. */
2026
+ function describeError(outcome) {
2027
+ if (!outcome.threw) return "";
2028
+ const { error } = outcome;
2029
+ return error instanceof Error ? error.message : String(error);
2030
+ }
2031
+ //#endregion
2032
+ //#region src/facets/mobile/mobile.result.ts
1427
2033
  /** Result from a `.open()` action — the screen as the device saw it, final state. */
1428
2034
  var ScreenResult = class extends BaseResult {
1429
2035
  capturedScreen;
@@ -1447,7 +2053,7 @@ var ScreenResult = class extends BaseResult {
1447
2053
  }
1448
2054
  };
1449
2055
  //#endregion
1450
- //#region src/specification/facets/website/result.ts
2056
+ //#region src/facets/website/website.result.ts
1451
2057
  /** Result from a raw `.fetch()` action (robots.txt, sitemaps, redirects). */
1452
2058
  var FetchResult = class extends BaseResult {
1453
2059
  exchange;
@@ -1499,7 +2105,8 @@ var PageResult = class extends BaseResult {
1499
2105
  * with `toBeEmpty()`, or snapshot with `toMatch('home.console.txt')`.
1500
2106
  */
1501
2107
  get console() {
1502
- return new TextAccessor(this.page.consoleMessages.map((m) => `[${m.type}] ${m.text}`).join("\n"), "console", this.testDir, { captures: this.captures });
2108
+ const lines = this.page.consoleMessages.map((m) => `[${m.type}] ${m.text}`).join("\n");
2109
+ return new TextAccessor(lines, "console", this.testDir, { captures: this.captures });
1503
2110
  }
1504
2111
  /**
1505
2112
  * Console messages of type `error` only — the stream you usually want
@@ -1507,7 +2114,8 @@ var PageResult = class extends BaseResult {
1507
2114
  * a cli result.
1508
2115
  */
1509
2116
  get errors() {
1510
- return new TextAccessor(this.page.consoleMessages.filter((m) => m.type === "error").map((m) => m.text).join("\n"), "errors", this.testDir, { captures: this.captures });
2117
+ const lines = this.page.consoleMessages.filter((m) => m.type === "error").map((m) => m.text).join("\n");
2118
+ return new TextAccessor(lines, "errors", this.testDir, { captures: this.captures });
1511
2119
  }
1512
2120
  /**
1513
2121
  * The document-head projection as a JSON accessor — title, canonical,
@@ -1532,7 +2140,8 @@ var PageResult = class extends BaseResult {
1532
2140
  * array — `expect(result.jsonLd).toMatch('article.jsonld.json')`.
1533
2141
  */
1534
2142
  get jsonLd() {
1535
- return new JsonAccessor(`[${this.page.jsonLdBlocks.join(",")}]`, this.testDir, void 0, this.captures);
2143
+ const combined = `[${this.page.jsonLdBlocks.join(",")}]`;
2144
+ return new JsonAccessor(combined, this.testDir, void 0, this.captures);
1536
2145
  }
1537
2146
  /** `<link>` elements of the head, in DOM order. */
1538
2147
  get links() {
@@ -1578,128 +2187,7 @@ function namedMetas(metas) {
1578
2187
  return named;
1579
2188
  }
1580
2189
  //#endregion
1581
- //#region src/specification/facets/_common/binding.ts
1582
- /**
1583
- * Compose-binding resolution and the case conversions it shares with the
1584
- * automatic env injection (CONVENTIONS A6 / B6).
1585
- *
1586
- * A services-record key is written in the natural TypeScript style
1587
- * (`analyticsDb`), while a compose service name is kebab-case
1588
- * (`analytics-db`). These helpers bridge the two vocabularies deterministically
1589
- * so the record key stays the single source of truth and `composeService`
1590
- * remains a rare escape hatch.
1591
- */
1592
- /**
1593
- * Convert a record key to its kebab-case form: camelCase boundaries become
1594
- * `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
1595
- */
1596
- function toKebabCase(key) {
1597
- return key.replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, "$<lower>-$<upper>").toLowerCase();
1598
- }
1599
- /**
1600
- * Convert a record key to CONSTANT_CASE for env injection: insert `_` at
1601
- * camelCase boundaries, map any remaining non-alphanumeric run to `_`, then
1602
- * uppercase. `analyticsDb` → `ANALYTICS_DB`, `db-main` → `DB_MAIN`, `db` → `DB`.
1603
- */
1604
- function toConstantCase(key) {
1605
- return key.replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, "$<lower>_$<upper>").replaceAll(/[^A-Za-z0-9]/gu, "_").toUpperCase();
1606
- }
1607
- /**
1608
- * Resolve the compose service a record key binds to (CONVENTIONS A6).
1609
- *
1610
- * Order:
1611
- * 1. An explicit `composeService` (`explicitComposeName`) always wins — the
1612
- * escape hatch for non-derivable names.
1613
- * 2. Otherwise the key binds to the compose service named exactly like it,
1614
- * else to the kebab-case conversion of the key.
1615
- * 3. If BOTH the exact key and its kebab-case form name a service in the
1616
- * compose file, the binding is ambiguous → throw (Lint runtime).
1617
- * 4. If neither is present, fall back to the key itself (single-word keys and
1618
- * runs without a compose file stay unchanged).
1619
- */
1620
- function resolveComposeBinding(key, explicitComposeName, serviceNames) {
1621
- if (explicitComposeName !== null) return explicitComposeName;
1622
- const kebab = toKebabCase(key);
1623
- const exactExists = serviceNames.includes(key);
1624
- const kebabExists = kebab !== key && serviceNames.includes(kebab);
1625
- if (exactExists && kebabExists) throw new Error(`Ambiguous compose binding for service key "${key}": the compose file declares both "${key}" and "${kebab}". Rename one service, or set composeService explicitly on the handle to pick the intended one.`);
1626
- if (kebabExists) return kebab;
1627
- return key;
1628
- }
1629
- //#endregion
1630
- //#region src/specification/facets/_common/caller.ts
1631
- /** Sibling module tests are callers, not internals (CONVENTIONS I2). */
1632
- const TEST_FILE = /\.test\.[cm]?[jt]s$/u;
1633
- /** Where this very module sits inside the framework's SOURCE tree. */
1634
- const SOURCE_LOCATION = join("specification", "facets", "_common");
1635
- /** The real path of `path`, or `path` itself when it cannot be resolved. */
1636
- function realPath(path) {
1637
- try {
1638
- return realpathSync(path);
1639
- } catch {
1640
- return path;
1641
- }
1642
- }
1643
- /**
1644
- * Directory of the running framework module itself. From the built package
1645
- * this is `<package root>/dist`, whatever the resolution path — including a
1646
- * `file:` link, where the real path carries neither `node_modules` nor
1647
- * `/src/…` and would otherwise be mistaken for a caller frame.
1648
- */
1649
- const FRAMEWORK_DIR = realPath(import.meta.dirname);
1650
- /**
1651
- * The directory holding the framework's OWN modules: `<package>/dist` for a
1652
- * built install (everything is bundled there), `<repo>/src` when the framework
1653
- * runs from source — this module's known location inside that tree is what
1654
- * tells the two apart.
1655
- */
1656
- const FRAMEWORK_TREE = FRAMEWORK_DIR.endsWith(`${sep}${SOURCE_LOCATION}`) ? resolve(FRAMEWORK_DIR, "../../..") : FRAMEWORK_DIR;
1657
- /** Is `path` inside `directory` (strictly — a directory is not inside itself)? */
1658
- function isInside(directory, path) {
1659
- const rest = relative(directory, path);
1660
- return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
1661
- }
1662
- /**
1663
- * Is this stack frame a module of the framework itself, rather than of the code
1664
- * calling it?
1665
- *
1666
- * Answered by IDENTITY: the frame is inside the framework's own directory.
1667
- * Answering it by substring — `/src/core/`, `/src/integrations/`, `/src/vitest/`
1668
- * — was answering a different question, "does this path look like the framework's
1669
- * layout", and a consumer that happens to have its own `src/core/` matched it.
1670
- * Its files were then skipped as framework internals, and fixture resolution
1671
- * anchored on the wrong directory (or fell through to the cwd) with nothing
1672
- * reported.
1673
- *
1674
- * @internal
1675
- */
1676
- function isFrameworkFrame(filePath, frameworkTree = FRAMEWORK_TREE) {
1677
- if (TEST_FILE.test(filePath)) return false;
1678
- return isInside(realPath(frameworkTree), realPath(filePath));
1679
- }
1680
- /**
1681
- * Detect the directory of the first stack frame that lives outside this
1682
- * package. Used to anchor fixture resolution (`_seeds/`, `_requests/`,
1683
- * `_expected/`, …) on the calling test / specification file.
1684
- *
1685
- * @internal
1686
- */
1687
- function getCallerDir() {
1688
- const { stack } = /* @__PURE__ */ new Error("caller detection");
1689
- if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
1690
- const lines = stack.split("\n");
1691
- for (const line of lines) {
1692
- const match = /at\s+(?:.*?\()?(?:file:\/\/)?(?<filePath>[^:)]+):\d+:\d+/u.exec(line);
1693
- if (!match?.groups?.filePath) continue;
1694
- const { filePath } = match.groups;
1695
- if (filePath.includes("node_modules")) continue;
1696
- if (isFrameworkFrame(filePath)) continue;
1697
- return resolve(filePath, "..");
1698
- }
1699
- return process.cwd();
1700
- }
1701
- //#endregion
1702
- //#region src/specification/facets/_common/builder.ts
2190
+ //#region src/model/chain/builder.ts
1703
2191
  /**
1704
2192
  * Fluent builder for declaring a single test specification.
1705
2193
  *
@@ -1713,6 +2201,7 @@ function getCallerDir() {
1713
2201
  * only surfaces the methods that make sense for it.
1714
2202
  */
1715
2203
  var SpecificationBuilder = class {
2204
+ pinnedClock = null;
1716
2205
  commandEnv = {};
1717
2206
  config;
1718
2207
  contracts = [];
@@ -1725,6 +2214,21 @@ var SpecificationBuilder = class {
1725
2214
  this.testDir = testDir;
1726
2215
  }
1727
2216
  /**
2217
+ * Pin the clock at `iso` for this chain.
2218
+ *
2219
+ * In-process facets (api, jobs, integration) pin the runner's own `Date`,
2220
+ * which is the app's; a website visit pins the PAGE's, through the browser.
2221
+ * Either way the clock is released when the action resolves, so a chain
2222
+ * never leaks its instant into the next one (CONVENTIONS D16).
2223
+ *
2224
+ * @example
2225
+ * const result = await api.clock('2026-03-04T09:30:00Z').get('/now');
2226
+ */
2227
+ clock(iso) {
2228
+ this.pinnedClock = iso;
2229
+ return this;
2230
+ }
2231
+ /**
1728
2232
  * Queue a SQL seed file to run before the action.
1729
2233
  *
1730
2234
  * With two or more declared databases the `database` option is mandatory;
@@ -1793,7 +2297,6 @@ var SpecificationBuilder = class {
1793
2297
  return this;
1794
2298
  }
1795
2299
  intercept(requestOrContracts, maybeResponse) {
1796
- if (this.config.interceptDisabledReason) throw new Error(`.intercept(): ${this.config.interceptDisabledReason}`);
1797
2300
  if ((this.config.baseUrl !== void 0 || this.config.device !== void 0) && !this.config.backend) {
1798
2301
  const facet = this.config.device === void 0 ? "website" : "mobile";
1799
2302
  throw new Error(`.intercept(): this runner has no declared backend — add \`backend\` to the specification options (specification.${facet}({ …, backend: { … } })) so the chain's contracts have a stub to serve them.`);
@@ -1925,6 +2428,7 @@ var SpecificationBuilder = class {
1925
2428
  * expect(result.body).toMatch('robots.txt');
1926
2429
  */
1927
2430
  async fetch(path) {
2431
+ if (this.pinnedClock !== null) throw new Error(".clock() pins the clock of a PAGE, and .fetch() opens none — it is one raw HTTP exchange. Assert the moment with a token ({{iso8601}}) in the golden, or visit the page.");
1928
2432
  return await this.executeSetup(null, async () => await this.runFetchAction(path));
1929
2433
  }
1930
2434
  /**
@@ -1964,6 +2468,22 @@ var SpecificationBuilder = class {
1964
2468
  return await this.executeSetup(null, async () => await this.runOpenAction(deepLink, scenario));
1965
2469
  }
1966
2470
  /**
2471
+ * Call the module under test and resolve with what it produced — the
2472
+ * value it returned, or the error it threw.
2473
+ *
2474
+ * A refusal is a reading, never a `try`/`catch`: `result.error` carries
2475
+ * what was thrown, and `result.value` what was returned. That is what
2476
+ * keeps a spec of a refusal the same size as a spec of a success, and
2477
+ * what stops a "it should throw" spec from passing when nothing does.
2478
+ *
2479
+ * @example
2480
+ * const result = await integration.seed('rows.sql').call(({ db }) => find(db.connectionString, id));
2481
+ * expect(result.value).toMatch('found.json');
2482
+ */
2483
+ async call(subject) {
2484
+ return await this.executeSetup(null, async () => await this.runCallAction(subject));
2485
+ }
2486
+ /**
1967
2487
  * Execute the named job registered via the `jobs` option of
1968
2488
  * `specification.jobs()` and resolve with the result.
1969
2489
  *
@@ -2004,9 +2524,11 @@ var SpecificationBuilder = class {
2004
2524
  let registration = null;
2005
2525
  if (this.config.backend) this.config.backend.beginChain(this.contracts);
2006
2526
  else if (this.contracts.length > 0) {
2007
- const { registerContracts } = await import("./intercept.js");
2527
+ const { registerContracts } = await import("./server.js");
2008
2528
  registration = await registerContracts(this.contracts);
2009
2529
  }
2530
+ const instant = this.pinnedClock;
2531
+ const pinned = instant !== null && this.config.baseUrl === void 0 ? clock.at(instant) : null;
2010
2532
  try {
2011
2533
  const value = await action();
2012
2534
  const violation = registration?.violation() ?? this.config.backend?.violation();
@@ -2015,6 +2537,7 @@ var SpecificationBuilder = class {
2015
2537
  } catch (error) {
2016
2538
  throw registration?.violation() ?? this.config.backend?.violation() ?? error;
2017
2539
  } finally {
2540
+ pinned?.[Symbol.dispose]();
2018
2541
  registration?.cleanup();
2019
2542
  }
2020
2543
  }
@@ -2025,6 +2548,28 @@ var SpecificationBuilder = class {
2025
2548
  prepareWorkDir() {
2026
2549
  return mkdtempSync(resolve(tmpdir(), "spec-command-"));
2027
2550
  }
2551
+ async runCallAction(subject) {
2552
+ const services = this.config.services ?? {};
2553
+ let outcome;
2554
+ try {
2555
+ outcome = {
2556
+ error: null,
2557
+ threw: false,
2558
+ value: await subject(services)
2559
+ };
2560
+ } catch (error) {
2561
+ outcome = {
2562
+ error,
2563
+ threw: true,
2564
+ value: null
2565
+ };
2566
+ }
2567
+ return new CallResult({
2568
+ config: this.config,
2569
+ outcome,
2570
+ testDir: this.testDir
2571
+ });
2572
+ }
2028
2573
  async runHttpAction(request) {
2029
2574
  if (!this.config.server) throw new Error("HTTP actions require a server adapter (use specification.api())");
2030
2575
  let { body, method, path } = request;
@@ -2076,6 +2621,7 @@ var SpecificationBuilder = class {
2076
2621
  const page = await browser.open(`${baseUrl}${path}`, {
2077
2622
  allowedOrigins: this.config.backendUrl ? [new URL(this.config.backendUrl).origin] : void 0,
2078
2623
  baseUrl,
2624
+ ...this.pinnedClock === null ? {} : { clock: this.pinnedClock },
2079
2625
  external: this.config.external ?? "allow",
2080
2626
  headers,
2081
2627
  scenario
@@ -2145,7 +2691,11 @@ var SpecificationBuilder = class {
2145
2691
  * the chain's own `.env()` — which always wins, `null` unsetting.
2146
2692
  */
2147
2693
  childEnv(workDir) {
2148
- let env = this.serviceEnv();
2694
+ const declared = this.config.defaultEnv;
2695
+ let env = declared ? {
2696
+ ...expandWorkdir(declared, workDir),
2697
+ ...this.serviceEnv()
2698
+ } : this.serviceEnv();
2149
2699
  const { dockerConfig } = this.config;
2150
2700
  if (dockerConfig && this.config.dockerTestRunId) env = {
2151
2701
  ...env,
@@ -2221,6 +2771,7 @@ function withDockerTestRunId(config) {
2221
2771
  function createApiFacet(config) {
2222
2772
  const start = () => new SpecificationBuilder(config, getCallerDir());
2223
2773
  return {
2774
+ clock: (iso) => start().clock(iso),
2224
2775
  delete: async (path) => await start().delete(path),
2225
2776
  get: async (path) => await start().get(path),
2226
2777
  headers: (headers) => start().headers(headers),
@@ -2237,17 +2788,31 @@ function createApiFacet(config) {
2237
2788
  function createJobsFacet(config) {
2238
2789
  const start = () => new SpecificationBuilder(config, getCallerDir());
2239
2790
  return {
2791
+ clock: (iso) => start().clock(iso),
2240
2792
  intercept: interceptOn(start),
2241
2793
  seed: (file, options) => start().seed(file, options),
2242
2794
  trigger: async (name) => await start().trigger(name)
2243
2795
  };
2244
2796
  }
2245
2797
  /**
2798
+ * Create the `integration` facet bound to the given adapter configuration.
2799
+ */
2800
+ function createIntegrationFacet(config) {
2801
+ const start = () => new SpecificationBuilder(config, getCallerDir());
2802
+ return {
2803
+ call: async (subject) => await start().call(subject),
2804
+ clock: (iso) => start().clock(iso),
2805
+ intercept: interceptOn(start),
2806
+ seed: (file, options) => start().seed(file, options)
2807
+ };
2808
+ }
2809
+ /**
2246
2810
  * Create the `website` facet bound to the given adapter configuration.
2247
2811
  */
2248
2812
  function createWebsiteFacet(config) {
2249
2813
  const start = () => new SpecificationBuilder(config, getCallerDir());
2250
2814
  return {
2815
+ clock: (iso) => start().clock(iso),
2251
2816
  fetch: async (path) => await start().fetch(path),
2252
2817
  headers: (headers) => start().headers(headers),
2253
2818
  intercept: interceptOn(start),
@@ -2279,448 +2844,31 @@ function createCliFacet(config) {
2279
2844
  };
2280
2845
  }
2281
2846
  //#endregion
2282
- //#region src/specification/facets/_common/docker-reader.ts
2283
- /**
2284
- * Build the `docker(containerId)` reader handed out by `specification.api()`
2285
- * and `specification.cli()` — a lazy accessor over `docker inspect`.
2286
- */
2287
- function createDockerReader(testDir) {
2288
- return (containerId) => {
2289
- try {
2290
- return new ContainerAccessor(containerId, inspectContainer(containerId), testDir);
2291
- } catch {
2292
- return new ContainerAccessor(null, null, testDir);
2293
- }
2294
- };
2295
- }
2296
- //#endregion
2297
- //#region src/specification/facets/_common/compose-file.ts
2298
- /**
2299
- * Detect the service type from the image name.
2300
- */
2301
- function detectServiceType(image) {
2302
- if (!image) return "app";
2303
- const lower = image.toLowerCase();
2304
- if (lower.startsWith("postgres")) return "postgres";
2305
- if (lower.startsWith("redis")) return "redis";
2306
- return "unknown";
2307
- }
2308
- /**
2309
- * Find the compose file in the project.
2310
- * Looks for docker/compose.test.yaml or docker-compose.test.yaml.
2311
- */
2312
- function findComposeFile(projectRoot) {
2313
- const candidates = [
2314
- resolve(projectRoot, "docker/compose.test.yaml"),
2315
- resolve(projectRoot, "docker/compose.test.yml"),
2316
- resolve(projectRoot, "docker-compose.test.yaml"),
2317
- resolve(projectRoot, "docker-compose.test.yml")
2318
- ];
2319
- for (const candidate of candidates) if (existsSync(candidate)) return candidate;
2320
- return null;
2321
- }
2322
- //#endregion
2323
- //#region src/specification/facets/_common/orchestrator.ts
2324
- /**
2325
- * Orchestrator for test infrastructure.
2326
- * Integration: starts services via testcontainers.
2327
- * E2E: runs full docker compose up.
2328
- */
2329
- var Orchestrator = class {
2330
- services;
2331
- mode;
2332
- root;
2333
- projectName;
2334
- running = [];
2335
- composeStack = null;
2336
- composeHandles = [];
2337
- started = false;
2338
- constructor(options) {
2339
- this.services = options.services;
2340
- this.mode = options.mode;
2341
- this.root = options.root;
2342
- this.projectName = options.projectName;
2343
- }
2344
- /**
2345
- * Bind each declared handle to a compose service (CONVENTIONS A6): a record
2346
- * key resolves to the service named exactly like it, else the kebab-case
2347
- * conversion of the key; an explicit `composeService` wins. Throws on an
2348
- * ambiguous binding (both names present). Runs once compose config is known
2349
- * so the two-step resolution can consult the real service list.
2350
- */
2351
- resolveBindings(composeConfig) {
2352
- const serviceNames = composeConfig?.services.map((s) => s.name) ?? [];
2353
- for (const [key, handle] of Object.entries(this.services)) handle.composeName = resolveComposeBinding(key, handle.composeName, serviceNames);
2354
- }
2355
- /**
2356
- * Start declared services via testcontainers (integration mode).
2357
- * Phase 1: start all containers in parallel (the slow part).
2358
- * Phase 2: wire connections, healthcheck, and init sequentially (fast).
2359
- */
2360
- async start() {
2361
- if (this.started) return;
2362
- const composePath = findComposeFile(this.root);
2363
- const composeDir = composePath ? dirname(composePath) : this.root;
2364
- const composeConfig = composePath ? getContainerIntegrations().parseComposeFile(composePath) : null;
2365
- this.resolveBindings(composeConfig);
2366
- const containerServices = [];
2367
- const embeddedServices = [];
2368
- for (const handle of Object.values(this.services)) {
2369
- if (handle.defaultPort === 0) {
2370
- embeddedServices.push(handle);
2371
- continue;
2372
- }
2373
- let image = handle.defaultImage;
2374
- let env = { ...handle.environment };
2375
- if (handle.composeName && composeConfig) {
2376
- const composeService = composeConfig.services.find((s) => s.name === handle.composeName);
2377
- if (composeService) {
2378
- image = composeService.image ?? image;
2379
- env = {
2380
- ...env,
2381
- ...composeService.environment
2382
- };
2383
- Object.assign(handle.environment, composeService.environment);
2384
- }
2385
- }
2386
- const container = getContainerIntegrations().createContainer({
2387
- image,
2388
- port: handle.defaultPort,
2389
- env
2390
- });
2391
- containerServices.push({
2392
- container,
2393
- handle
2394
- });
2395
- }
2396
- await Promise.all([...containerServices.map(async ({ container }) => {
2397
- await container.start();
2398
- }), ...embeddedServices.map(async (handle) => {
2399
- await handle.initialize(composeDir, this.root);
2400
- handle.started = true;
2401
- this.running.push({
2402
- handle,
2403
- container: null
2404
- });
2405
- })]);
2406
- const reports = [];
2407
- for (const { container, handle } of containerServices) {
2408
- const serviceStartTime = Date.now();
2409
- try {
2410
- const host = container.getHost();
2411
- const port = container.getMappedPort(handle.defaultPort);
2412
- handle.connectionString = handle.buildConnectionString(host, port);
2413
- await handle.healthcheck();
2414
- await handle.initialize(composeDir, this.root);
2415
- handle.started = true;
2416
- reports.push({
2417
- name: handle.composeName ?? handle.type,
2418
- type: handle.type,
2419
- connectionString: handle.connectionString,
2420
- durationMs: Date.now() - serviceStartTime
2421
- });
2422
- this.running.push({
2423
- handle,
2424
- container
2425
- });
2426
- } catch (error) {
2427
- let logs = "";
2428
- try {
2429
- logs = await container.getLogs();
2430
- } catch {}
2431
- try {
2432
- await container.stop();
2433
- } catch {}
2434
- reports.push({
2435
- name: handle.composeName ?? handle.type,
2436
- type: handle.type,
2437
- durationMs: Date.now() - serviceStartTime,
2438
- error: error.message,
2439
- logs
2440
- });
2441
- const output = formatStartupReport("integration", reports, { type: "in-process" });
2442
- console.error(output);
2443
- throw error;
2444
- }
2445
- }
2446
- this.started = true;
2447
- const output = formatStartupReport("integration", reports, { type: "in-process" });
2448
- console.log(output);
2449
- }
2450
- /**
2451
- * Stop testcontainers (integration mode).
2452
- */
2453
- async stop() {
2454
- for (const { container } of this.running) if (container) await container.stop();
2455
- this.running = [];
2456
- this.started = false;
2457
- }
2458
- /**
2459
- * Start full docker compose stack (e2e mode).
2460
- * Auto-detects infra services and creates handles for them.
2461
- */
2462
- async startCompose() {
2463
- const composePath = findComposeFile(this.root);
2464
- if (!composePath) throw new Error(`E2E: no compose file found in ${this.root}`);
2465
- const startTime = Date.now();
2466
- const composeDir = dirname(composePath);
2467
- const composeConfig = getContainerIntegrations().parseComposeFile(composePath);
2468
- this.resolveBindings(composeConfig);
2469
- this.composeStack = getContainerIntegrations().createComposeStack(composePath, this.projectName);
2470
- await this.composeStack.start();
2471
- const declaredComposeNames = /* @__PURE__ */ new Set();
2472
- for (const handle of Object.values(this.services)) {
2473
- if (handle.defaultPort === 0) {
2474
- await handle.initialize(composeDir, this.root);
2475
- handle.started = true;
2476
- continue;
2477
- }
2478
- const composeService = composeConfig.services.find((s) => s.name === handle.composeName);
2479
- if (!composeService || !handle.composeName) continue;
2480
- declaredComposeNames.add(handle.composeName);
2481
- Object.assign(handle.environment, composeService.environment);
2482
- const port = this.composeStack.getMappedPort(handle.composeName, handle.defaultPort);
2483
- handle.connectionString = handle.buildConnectionString("localhost", port);
2484
- await handle.healthcheck();
2485
- await handle.initialize(composeDir, this.root);
2486
- handle.started = true;
2487
- }
2488
- for (const service of composeConfig.infraServices) {
2489
- if (declaredComposeNames.has(service.name)) continue;
2490
- const factory = getComposeServiceFactory(detectServiceType(service.image));
2491
- if (!factory) continue;
2492
- const handle = factory(service);
2493
- const port = this.composeStack.getMappedPort(service.name, handle.defaultPort);
2494
- handle.connectionString = handle.buildConnectionString("localhost", port);
2495
- await handle.initialize(composeDir, this.root);
2496
- handle.started = true;
2497
- this.composeHandles.push(handle);
2498
- }
2499
- const durationMs = Date.now() - startTime;
2500
- const output = formatStartupReport("e2e", [...this.allHandles().values()].filter((h) => h.started).map((h) => ({
2501
- name: h.composeName ?? h.type,
2502
- type: h.type,
2503
- connectionString: h.connectionString,
2504
- durationMs
2505
- })), {
2506
- type: "http",
2507
- url: this.getAppUrl() ?? void 0
2508
- });
2509
- console.log(output);
2510
- }
2511
- /**
2512
- * Stop docker compose stack (e2e mode).
2513
- */
2514
- async stopCompose() {
2515
- if (this.composeStack) {
2516
- await this.composeStack.stop();
2517
- this.composeStack = null;
2518
- }
2519
- this.composeHandles = [];
2520
- }
2521
- /**
2522
- * Get the default database — the first declared handle that is one.
2523
- */
2524
- getDatabase() {
2525
- for (const handle of this.allHandles().values()) {
2526
- const adapter = handle.createDatabaseAdapter();
2527
- if (adapter) return adapter;
2528
- }
2529
- return null;
2530
- }
2531
- /**
2532
- * Get all database services keyed by their record key (declared services)
2533
- * or compose service name (stack-detected services).
2534
- */
2535
- getDatabases() {
2536
- const map = /* @__PURE__ */ new Map();
2537
- for (const [key, handle] of this.allHandles()) {
2538
- const adapter = handle.createDatabaseAdapter();
2539
- if (adapter) map.set(key, adapter);
2540
- }
2541
- return map;
2542
- }
2543
- allHandles() {
2544
- const map = new Map(Object.entries(this.services));
2545
- for (const handle of this.composeHandles) {
2546
- const key = handle.composeName ?? handle.type;
2547
- if (!map.has(key)) map.set(key, handle);
2548
- }
2549
- return map;
2550
- }
2551
- /**
2552
- * Get app URL from compose (e2e mode).
2553
- */
2554
- getAppUrl() {
2555
- const composePath = findComposeFile(this.root);
2556
- if (!composePath || !this.composeStack) return null;
2557
- const { appService } = getContainerIntegrations().parseComposeFile(composePath);
2558
- const [firstPort] = appService?.ports ?? [];
2559
- if (!appService || firstPort === void 0) return null;
2560
- return `http://localhost:${this.composeStack.getMappedPort(appService.name, firstPort.container)}`;
2561
- }
2562
- };
2563
- //#endregion
2564
- //#region src/specification/facets/_common/services.ts
2565
- function getWorkerId() {
2566
- return process.env.VITEST_POOL_ID ?? "0";
2567
- }
2568
- async function acquireIsolation(services) {
2569
- const workerId = getWorkerId();
2570
- for (const service of Object.values(services)) await service.isolation().acquire(workerId);
2571
- }
2572
- async function releaseIsolation(services) {
2573
- for (const service of Object.values(services)) await service.isolation().release();
2574
- }
2575
- function declaredDatabaseKeys(services) {
2576
- return Object.entries(services).filter(([, service]) => service.createDatabaseAdapter() !== null).map(([key]) => key);
2577
- }
2578
- /** Start a services record via testcontainers and acquire worker isolation. */
2579
- async function startServices(services, root) {
2580
- const orchestrator = new Orchestrator({
2581
- mode: "integration",
2582
- root,
2583
- services
2584
- });
2585
- await orchestrator.start();
2586
- await acquireIsolation(services);
2587
- const databases = orchestrator.getDatabases();
2588
- return {
2589
- database: orchestrator.getDatabase() ?? void 0,
2590
- databases: databases.size > 0 ? databases : void 0,
2591
- orchestrator
2592
- };
2593
- }
2594
- //#endregion
2595
- //#region src/specification/facets/api/fetch.adapter.ts
2596
- /**
2597
- * Transient connection-level failures against a live compose app. Under
2598
- * parallel load (every `api-stack` worker hammering its own compose project)
2599
- * `fetch` intermittently rejects before the app's keep-alive socket is ready —
2600
- * `undici` surfaces these as `UND_ERR_SOCKET` / `ECONNRESET` / `ECONNREFUSED`.
2601
- * They are races, not real failures, so the request path retries them; an HTTP
2602
- * *response* (any status) is never retried — it is a real answer.
2603
- */
2604
- const TRANSIENT_CONNECTION_CODES = /* @__PURE__ */ new Set([
2605
- "ECONNREFUSED",
2606
- "ECONNRESET",
2607
- "UND_ERR_CONNECT_TIMEOUT",
2608
- "UND_ERR_SOCKET"
2609
- ]);
2610
- /** Bounded retry budget: attempts and the base backoff (doubled each retry). */
2611
- const MAX_ATTEMPTS = 5;
2612
- const BASE_BACKOFF_MS = 50;
2613
- function isTransientConnectionError(error) {
2614
- if (!(error instanceof Error)) return false;
2615
- const cause = error.cause;
2616
- return cause?.code !== void 0 && TRANSIENT_CONNECTION_CODES.has(cause.code) || error.message.includes("fetch failed");
2617
- }
2618
- const delay = async (ms) => {
2619
- await new Promise((resolve) => setTimeout(resolve, ms));
2620
- };
2621
- /**
2622
- * Server adapter that sends real HTTP requests via the Fetch API.
2623
- * Used by the `e2e()` specification runner to hit a live server.
2624
- */
2625
- var FetchAdapter = class {
2626
- baseUrl;
2627
- constructor(url) {
2628
- this.baseUrl = url.replace(/\/$/u, "");
2629
- }
2630
- async request(method, path, body, headers) {
2631
- const init = {
2632
- method,
2633
- headers: {
2634
- "Content-Type": "application/json",
2635
- ...headers
2636
- }
2637
- };
2638
- if (body !== void 0) init.body = typeof body === "string" ? body : JSON.stringify(body);
2639
- const response = await this.fetchWithRetry(`${this.baseUrl}${path}`, init);
2640
- const responseBody = await response.json().catch(() => null);
2641
- const responseHeaders = {};
2642
- response.headers.forEach((value, key) => {
2643
- responseHeaders[key] = value;
2644
- });
2645
- return {
2646
- status: response.status,
2647
- body: responseBody,
2648
- headers: responseHeaders
2649
- };
2650
- }
2651
- /**
2652
- * `fetch` with a bounded, backing-off retry over transient connection
2653
- * races (see {@link isTransientConnectionError}). The last error is rethrown
2654
- * once the budget is spent so a genuinely-down server still surfaces.
2655
- */
2656
- async fetchWithRetry(url, init) {
2657
- for (let attempt = 1;; attempt += 1) try {
2658
- return await fetch(url, init);
2659
- } catch (error) {
2660
- if (attempt >= MAX_ATTEMPTS || !isTransientConnectionError(error)) throw error;
2661
- await delay(BASE_BACKOFF_MS * 2 ** (attempt - 1));
2662
- }
2663
- }
2664
- };
2665
- //#endregion
2666
- //#region src/specification/facets/api/start-api.ts
2667
- function resolveMode(explicit) {
2668
- const value = explicit ?? process.env.TEST_MODE ?? "node";
2669
- if (value !== "compose" && value !== "node") throw new Error(`Invalid test mode "${value}" — expected 'node' or 'compose' (options.mode or TEST_MODE).`);
2670
- return value;
2671
- }
2847
+ //#region src/facets/api/api.specification.ts
2672
2848
  async function startApi(options) {
2673
2849
  const callerDir = getCallerDir();
2674
2850
  await registerMatchers();
2675
2851
  const root = resolveRoot(options.root, callerDir);
2676
- const mode = resolveMode(options.mode);
2677
2852
  const services = options.services ?? {};
2678
2853
  const databaseKeys = declaredDatabaseKeys(services);
2679
- if (mode === "node") {
2680
- if (!options.server) throw new Error("specification.api(): 'server' is required in node mode — provide server: (services) => app, or run in compose mode (TEST_MODE=compose).");
2681
- const { database, databases, orchestrator } = await startServices(services, root);
2682
- return {
2683
- api: createApiFacet({
2684
- database,
2685
- databaseKeys,
2686
- databases,
2687
- server: new HonoAdapter(options.server(services))
2688
- }),
2689
- cleanup: async () => {
2690
- await releaseIsolation(services);
2691
- await orchestrator.stop();
2692
- },
2693
- docker: createDockerReader(callerDir),
2694
- orchestrator
2695
- };
2696
- }
2697
- const orchestrator = new Orchestrator({
2698
- mode: "e2e",
2699
- projectName: `test-worker-${getWorkerId()}`,
2700
- root,
2701
- services
2702
- });
2703
- await orchestrator.startCompose();
2704
- const appUrl = orchestrator.getAppUrl();
2705
- if (!appUrl) throw new Error("specification.api(): could not detect app URL from compose. Ensure an app service with ports is defined.");
2706
- const databases = orchestrator.getDatabases();
2854
+ const { database, databases, orchestrator, stopProcesses } = await startServices(services, root);
2707
2855
  return {
2708
2856
  api: createApiFacet({
2709
- database: orchestrator.getDatabase() ?? void 0,
2857
+ database,
2710
2858
  databaseKeys,
2711
- databases: databases.size > 0 ? databases : void 0,
2712
- interceptDisabledReason: "intercepts are in-process (MSW) and not available in compose mode — keep intercept specs in node-only vitest projects.",
2713
- server: new FetchAdapter(appUrl)
2859
+ databases,
2860
+ server: new HonoAdapter(options.server(services))
2714
2861
  }),
2715
2862
  cleanup: async () => {
2716
- await orchestrator.stopCompose();
2863
+ await stopProcesses();
2864
+ await releaseIsolation(services);
2865
+ await orchestrator.stop();
2717
2866
  },
2718
- docker: createDockerReader(callerDir),
2719
- orchestrator
2867
+ docker: createDockerReader(callerDir)
2720
2868
  };
2721
2869
  }
2722
2870
  //#endregion
2723
- //#region src/specification/facets/cli/exec.adapter.ts
2871
+ //#region src/facets/cli/exec.adapter.ts
2724
2872
  const DEFAULT_WATCH_TIMEOUT = 1e4;
2725
2873
  /** Grace period between SIGTERM and the SIGKILL escalation. */
2726
2874
  const KILL_GRACE_MS = 2e3;
@@ -2875,7 +3023,7 @@ var ExecAdapter = class {
2875
3023
  }
2876
3024
  };
2877
3025
  //#endregion
2878
- //#region src/specification/facets/cli/start-cli.ts
3026
+ //#region src/facets/cli/cli.specification.ts
2879
3027
  async function startCli(bin, options = {}) {
2880
3028
  const callerDir = getCallerDir();
2881
3029
  await registerMatchers();
@@ -2884,9 +3032,10 @@ async function startCli(bin, options = {}) {
2884
3032
  const services = options.services ?? {};
2885
3033
  const databaseKeys = declaredDatabaseKeys(services);
2886
3034
  let started = null;
2887
- if (Object.keys(services).length > 0) started = await startServices(services, root);
3035
+ if (Object.keys(services).length > 0) started = await startServices(services, root, { type: "none" });
2888
3036
  return {
2889
3037
  cleanup: async () => {
3038
+ await started?.stopProcesses();
2890
3039
  await releaseIsolation(services);
2891
3040
  if (started) await started.orchestrator.stop();
2892
3041
  },
@@ -2896,6 +3045,7 @@ async function startCli(bin, options = {}) {
2896
3045
  databaseKeys,
2897
3046
  databases: started?.databases,
2898
3047
  dockerConfig: options.docker,
3048
+ defaultEnv: options.defaults,
2899
3049
  envSets: options.env,
2900
3050
  root,
2901
3051
  serveRegistry: options.serve,
@@ -2907,7 +3057,44 @@ async function startCli(bin, options = {}) {
2907
3057
  };
2908
3058
  }
2909
3059
  //#endregion
2910
- //#region src/specification/facets/jobs/start-jobs.ts
3060
+ //#region src/facets/integration/integration.specification.ts
3061
+ /**
3062
+ * Specify a module the way the assembled product is specified: against real
3063
+ * services and declared contracts, with the golden mechanism on what it
3064
+ * produces.
3065
+ *
3066
+ * The fork is the SUBJECT. A module alone is a module test, beside its code,
3067
+ * with no runner at all. A module that needs a real database, or whose oracle
3068
+ * is a golden file, is this one — which is what spares it the shape it would
3069
+ * otherwise take: plain vitest under a `specs/` folder, a hand-built sqlite
3070
+ * template, a `beforeAll` starting a container, and a `toMatchSnapshot` beside
3071
+ * a framework that owns a golden engine.
3072
+ */
3073
+ async function startIntegration(options = {}) {
3074
+ const callerDir = getCallerDir();
3075
+ await registerMatchers();
3076
+ const root = resolveRoot(options.root, callerDir);
3077
+ const services = options.services ?? {};
3078
+ const databaseKeys = declaredDatabaseKeys(services);
3079
+ let started = null;
3080
+ if (Object.keys(services).length > 0) started = await startServices(services, root, { type: "none" });
3081
+ return {
3082
+ cleanup: async () => {
3083
+ await started?.stopProcesses();
3084
+ await releaseIsolation(services);
3085
+ if (started) await started.orchestrator.stop();
3086
+ },
3087
+ integration: createIntegrationFacet({
3088
+ database: started?.database,
3089
+ databaseKeys,
3090
+ databases: started?.databases,
3091
+ root,
3092
+ services
3093
+ })
3094
+ };
3095
+ }
3096
+ //#endregion
3097
+ //#region src/facets/jobs/jobs.specification.ts
2911
3098
  async function startJobs(options) {
2912
3099
  const callerDir = getCallerDir();
2913
3100
  await registerMatchers();
@@ -2915,10 +3102,11 @@ async function startJobs(options) {
2915
3102
  const services = options.services ?? {};
2916
3103
  const databaseKeys = declaredDatabaseKeys(services);
2917
3104
  let started = null;
2918
- if (Object.keys(services).length > 0) started = await startServices(services, root);
3105
+ if (Object.keys(services).length > 0) started = await startServices(services, root, { type: "none" });
2919
3106
  const jobHandles = typeof options.jobs === "function" ? options.jobs(services) : options.jobs;
2920
3107
  return {
2921
3108
  cleanup: async () => {
3109
+ await started?.stopProcesses();
2922
3110
  await releaseIsolation(services);
2923
3111
  if (started) await started.orchestrator.stop();
2924
3112
  },
@@ -2932,7 +3120,7 @@ async function startJobs(options) {
2932
3120
  };
2933
3121
  }
2934
3122
  //#endregion
2935
- //#region src/specification/facets/_common/stub-backend.ts
3123
+ //#region src/model/chain/stub-backend.ts
2936
3124
  /**
2937
3125
  * The declared stub backend — a small `node:http` server the website and
2938
3126
  * mobile runners start when their `backend` option is present. It serves the
@@ -3067,6 +3255,22 @@ var StubBackend = class {
3067
3255
  if (reply.delay) await new Promise((resolve) => setTimeout(resolve, reply.delay));
3068
3256
  const status = reply.status ?? 200;
3069
3257
  const { body } = reply;
3258
+ if (isStreamBody(body)) {
3259
+ response.writeHead(status, {
3260
+ ...cors,
3261
+ "content-type": body.contentType,
3262
+ ...reply.headers
3263
+ });
3264
+ await toReadableStream(body).pipeTo(new WritableStream({
3265
+ close() {
3266
+ response.end();
3267
+ },
3268
+ write(chunk) {
3269
+ response.write(chunk);
3270
+ }
3271
+ }));
3272
+ return;
3273
+ }
3070
3274
  if (body === null || body === void 0) {
3071
3275
  response.writeHead(status, {
3072
3276
  ...cors,
@@ -3108,7 +3312,7 @@ async function readBody(request) {
3108
3312
  }
3109
3313
  }
3110
3314
  //#endregion
3111
- //#region src/specification/facets/mobile/appium-server.ts
3315
+ //#region src/facets/mobile/appium-server.ts
3112
3316
  const APPIUM_READY_TIMEOUT_MS = 6e4;
3113
3317
  /**
3114
3318
  * The guidance thrown when the optional peers are missing — the exact fix,
@@ -3139,7 +3343,7 @@ async function startAppiumServer(root) {
3139
3343
  };
3140
3344
  }
3141
3345
  //#endregion
3142
- //#region src/specification/facets/mobile/simulator.ts
3346
+ //#region src/facets/mobile/simulator.ts
3143
3347
  /**
3144
3348
  * Simulator resolution — `device: { name, os? }` → one booted UDID.
3145
3349
  *
@@ -3196,7 +3400,7 @@ async function resolveSimulatorUdid(criteria) {
3196
3400
  "list",
3197
3401
  "devices",
3198
3402
  "--json"
3199
- ], { maxBuffer: 16 * 1024 * 1024 });
3403
+ ], { maxBuffer: 16777216 });
3200
3404
  return selectSimulator(stdout, criteria).udid;
3201
3405
  }
3202
3406
  /**
@@ -3223,7 +3427,7 @@ async function ensureBooted(udid) {
3223
3427
  ], { timeout: BOOT_TIMEOUT_MS });
3224
3428
  }
3225
3429
  //#endregion
3226
- //#region src/specification/facets/mobile/start-mobile.ts
3430
+ //#region src/facets/mobile/mobile.specification.ts
3227
3431
  async function startMobile(options) {
3228
3432
  const callerDir = getCallerDir();
3229
3433
  await registerMatchers();
@@ -3231,6 +3435,14 @@ async function startMobile(options) {
3231
3435
  const udid = options.device.udid ?? await resolveSimulatorUdid(options.device);
3232
3436
  await ensureBooted(udid);
3233
3437
  const appium = await startAppiumServer(root);
3438
+ const services = options.services ?? {};
3439
+ let started = null;
3440
+ if (Object.keys(services).length > 0) try {
3441
+ started = await startServices(services, root, { type: "none" });
3442
+ } catch (error) {
3443
+ await appium.stop();
3444
+ throw error;
3445
+ }
3234
3446
  let backend = null;
3235
3447
  let backendUrl;
3236
3448
  if (options.backend) {
@@ -3262,6 +3474,11 @@ async function startMobile(options) {
3262
3474
  await device.close();
3263
3475
  device = null;
3264
3476
  }
3477
+ if (started) {
3478
+ await started.stopProcesses();
3479
+ await releaseIsolation(services);
3480
+ await started.orchestrator.stop();
3481
+ }
3265
3482
  await appium.stop();
3266
3483
  if (backend) await backend.stop();
3267
3484
  },
@@ -3270,7 +3487,77 @@ async function startMobile(options) {
3270
3487
  };
3271
3488
  }
3272
3489
  //#endregion
3273
- //#region src/specification/facets/website/start-website.ts
3490
+ //#region src/facets/website/website.specification.ts
3491
+ /**
3492
+ * The command a `server` states, where it states one.
3493
+ *
3494
+ * A factory is a function of services that have not started yet, so calling it
3495
+ * to read a command would start the world twice. The SHAPE is known either
3496
+ * way, and the shape is the part the startup report was getting wrong.
3497
+ */
3498
+ function commandOf(server) {
3499
+ return typeof server === "object" && server !== null && "command" in server ? String(server.command) : void 0;
3500
+ }
3501
+ /**
3502
+ * The declared shape, whichever of the three forms stated it — with an `env`
3503
+ * function resolved against the services that are already up, so the site is
3504
+ * handed the URL of the API it was started beside.
3505
+ */
3506
+ function resolveServer(server, services) {
3507
+ const stated = typeof server === "function" ? server(services) : server;
3508
+ const spec = stated instanceof ProcessHandle ? stated.spec : stated;
3509
+ if (typeof spec.env !== "function") return spec;
3510
+ return {
3511
+ ...spec,
3512
+ env: spec.env(services)
3513
+ };
3514
+ }
3515
+ /**
3516
+ * Start the declared record, if there is one. A record that fails to come up
3517
+ * must not orphan the stub that was started before it.
3518
+ */
3519
+ async function startDeclared(services, root, backend, subject) {
3520
+ if (Object.keys(services).length === 0) return null;
3521
+ try {
3522
+ return await startServices(services, root, subject);
3523
+ } catch (error) {
3524
+ await backend?.stop();
3525
+ throw error;
3526
+ }
3527
+ }
3528
+ /**
3529
+ * Start the site, or take the URL of a deployed one. A server that never came
3530
+ * up must not orphan the stub, nor the services it was started beside.
3531
+ */
3532
+ async function startSite(options, context) {
3533
+ if (!options.server) return {
3534
+ baseUrl: (options.url ?? "").replace(/\/$/u, ""),
3535
+ serve: null
3536
+ };
3537
+ const serve = new ServeAdapter(resolveServer(options.server, context.services), context.root, "website", {
3538
+ TEST_RUN_ID: context.started?.runId ?? randomUUID(),
3539
+ ...serverBackendEnv(options.backend, context.backendUrl)
3540
+ });
3541
+ try {
3542
+ return {
3543
+ baseUrl: await serve.start(),
3544
+ serve
3545
+ };
3546
+ } catch (error) {
3547
+ await context.started?.stopProcesses();
3548
+ await context.started?.orchestrator.stop();
3549
+ await context.backend?.stop();
3550
+ throw error;
3551
+ }
3552
+ }
3553
+ /** The stub's URL in the child's environment, under the name the caller chose. */
3554
+ function serverBackendEnv(backend, backendUrl) {
3555
+ return backend && backendUrl !== void 0 ? { [backend.env]: backendUrl } : {};
3556
+ }
3557
+ /** The declared record, or the empty one — a website may have no services. */
3558
+ function servicesOf(options) {
3559
+ return options.services ?? {};
3560
+ }
3274
3561
  async function startWebsite(options) {
3275
3562
  const callerDir = getCallerDir();
3276
3563
  await registerMatchers();
@@ -3281,18 +3568,23 @@ async function startWebsite(options) {
3281
3568
  backend = new StubBackend({ port: options.backend.port });
3282
3569
  backendUrl = await backend.start();
3283
3570
  }
3284
- let serve = null;
3285
- let baseUrl;
3286
- if (options.server) {
3287
- const root = resolveRoot(options.root, callerDir);
3288
- serve = new ServeAdapter(options.server, root, "website", options.backend && backendUrl !== void 0 ? { [options.backend.env]: backendUrl } : {});
3289
- try {
3290
- baseUrl = await serve.start();
3291
- } catch (error) {
3292
- await backend?.stop();
3293
- throw error;
3294
- }
3295
- } else baseUrl = options.url.replace(/\/$/u, "");
3571
+ const services = servicesOf(options);
3572
+ const root = resolveRoot(options.root, callerDir);
3573
+ const subject = options.server ? {
3574
+ command: commandOf(options.server),
3575
+ type: "process"
3576
+ } : {
3577
+ type: "http",
3578
+ url: options.url
3579
+ };
3580
+ const started = await startDeclared(services, root, backend, subject);
3581
+ const { baseUrl, serve } = await startSite(options, {
3582
+ backend,
3583
+ backendUrl,
3584
+ root,
3585
+ services,
3586
+ started
3587
+ });
3296
3588
  let browser = null;
3297
3589
  const getBrowser = async () => {
3298
3590
  if (!browser) {
@@ -3301,13 +3593,6 @@ async function startWebsite(options) {
3301
3593
  }
3302
3594
  return browser;
3303
3595
  };
3304
- const config = {
3305
- backend: backend ?? void 0,
3306
- backendUrl,
3307
- baseUrl,
3308
- browser: getBrowser,
3309
- external: options.external ?? (options.server ? "block" : "allow")
3310
- };
3311
3596
  return {
3312
3597
  cleanup: async () => {
3313
3598
  if (browser) {
@@ -3315,21 +3600,31 @@ async function startWebsite(options) {
3315
3600
  browser = null;
3316
3601
  }
3317
3602
  if (serve) await serve.stop();
3603
+ if (started) {
3604
+ await started.stopProcesses();
3605
+ await releaseIsolation(services);
3606
+ await started.orchestrator.stop();
3607
+ }
3318
3608
  if (backend) await backend.stop();
3319
3609
  },
3320
3610
  url: baseUrl,
3321
- website: createWebsiteFacet(config)
3611
+ website: createWebsiteFacet({
3612
+ backend: backend ?? void 0,
3613
+ backendUrl,
3614
+ baseUrl,
3615
+ browser: getBrowser,
3616
+ external: options.external ?? (options.server ? "block" : "allow")
3617
+ })
3322
3618
  };
3323
3619
  }
3324
3620
  //#endregion
3325
- //#region src/specification/facets/_common/specification.ts
3621
+ //#region src/facets/specification.ts
3326
3622
  const specification = {
3327
3623
  /**
3328
- * Test an HTTP app. Mode `'node'` (default) starts the declared services
3329
- * via testcontainers and runs the app in-process; mode `'compose'` runs
3330
- * `docker compose up` on `docker/compose.test.yaml` and sends real HTTP
3331
- * requests to the app service. Resolution: `options.mode` > `TEST_MODE`
3332
- * env var > `'node'`. Only `.api()` has a mode.
3624
+ * Test an HTTP app. The declared `services` are started via testcontainers
3625
+ * and the app `server` builds runs in-process, so an outgoing call passes
3626
+ * through this process and `.intercept()` can answer it. There is one
3627
+ * shape of world — the record key is the service's only name.
3333
3628
  */
3334
3629
  api: startApi,
3335
3630
  /**
@@ -3339,6 +3634,13 @@ const specification = {
3339
3634
  */
3340
3635
  cli: startCli,
3341
3636
  /**
3637
+ * Specify a module against the real thing: a database, a cache, a
3638
+ * `process()` it talks to — or nothing at all, when its oracle is a
3639
+ * golden. `.call((services) => …)` is the terminal action, and what it
3640
+ * produced is read as `result.value` or, when it refused, `result.error`.
3641
+ */
3642
+ integration: startIntegration,
3643
+ /**
3342
3644
  * Test background jobs. Jobs run in-process by definition — no HTTP
3343
3645
  * server, no mode. `.trigger(name)` is the terminal action.
3344
3646
  */
@@ -3360,7 +3662,7 @@ const specification = {
3360
3662
  website: startWebsite
3361
3663
  };
3362
3664
  //#endregion
3363
- //#region src/specification/facets/component/component.node.ts
3665
+ //#region src/facets/component/component.specification.ts
3364
3666
  /**
3365
3667
  * `component`, as node sees it.
3366
3668
  *
@@ -3382,7 +3684,7 @@ const component = {
3382
3684
  wrap: refuse
3383
3685
  };
3384
3686
  //#endregion
3385
- //#region src/specification/facets/_common/result/text-subject.ts
3687
+ //#region src/model/result/text-subject.ts
3386
3688
  /**
3387
3689
  * Wrap an arbitrary string into a {@link TextAccessor} anchored on the calling
3388
3690
  * test's directory — the same caller-detection the builders use.
@@ -3410,7 +3712,218 @@ function text(value) {
3410
3712
  return new TextAccessor(value, "text", getCallerDir());
3411
3713
  }
3412
3714
  //#endregion
3413
- //#region src/integrations/sqlite/sqlite.ts
3715
+ //#region src/seams/postgres/postgres.ts
3716
+ /**
3717
+ * `pg`'s client constructor, loaded on the first connection.
3718
+ *
3719
+ * The driver is an OPTIONAL peer: a repository that specifies a CLI never
3720
+ * reaches this line, and never pays for the import.
3721
+ */
3722
+ async function clientConstructor() {
3723
+ return await loadPeer("pg", "postgres()", async () => {
3724
+ return (await import("pg")).Client;
3725
+ });
3726
+ }
3727
+ var PostgresHandle = class {
3728
+ type = "postgres";
3729
+ serviceName = null;
3730
+ defaultPort = 5432;
3731
+ defaultImage;
3732
+ environment;
3733
+ connectionString = "";
3734
+ started = false;
3735
+ client = null;
3736
+ originalConnectionString = "";
3737
+ schema = "public";
3738
+ constructor(options = {}) {
3739
+ this.defaultImage = options.image ?? "postgres:17";
3740
+ this.environment = {
3741
+ POSTGRES_DB: "test",
3742
+ POSTGRES_PASSWORD: "test",
3743
+ POSTGRES_USER: "test",
3744
+ ...options.env
3745
+ };
3746
+ }
3747
+ buildConnectionString(host, port) {
3748
+ return `postgresql://${this.environment.POSTGRES_USER ?? "test"}:${this.environment.POSTGRES_PASSWORD ?? "test"}@${host}:${port}/${this.environment.POSTGRES_DB ?? "test"}`;
3749
+ }
3750
+ createDatabaseAdapter() {
3751
+ return this;
3752
+ }
3753
+ async healthcheck() {
3754
+ if (!this.connectionString) throw new Error("postgres: cannot healthcheck — no connection string");
3755
+ try {
3756
+ const client = new (await (clientConstructor()))({ connectionString: this.connectionString });
3757
+ await client.connect();
3758
+ await client.query("SELECT 1");
3759
+ await client.end();
3760
+ } catch (error) {
3761
+ throw new Error(`postgres healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
3762
+ }
3763
+ }
3764
+ async initialize(dockerDir) {
3765
+ if (!this.serviceName) return;
3766
+ const initPaths = [resolve(dockerDir, `${this.serviceName}/init.sql`), resolve(dockerDir, "postgres/init.sql")];
3767
+ for (const initPath of initPaths) if (existsSync(initPath)) {
3768
+ const sql = readFileSync(initPath, "utf8");
3769
+ try {
3770
+ await this.seed(sql);
3771
+ } catch (error) {
3772
+ throw new Error(`postgres init script failed (${initPath}):\n${error.message}`, { cause: error });
3773
+ }
3774
+ return;
3775
+ }
3776
+ }
3777
+ async getClient() {
3778
+ if (this.client) return this.client;
3779
+ const client = new (await (clientConstructor()))({ connectionString: this.connectionString });
3780
+ client.on("error", () => {
3781
+ this.client = null;
3782
+ });
3783
+ await client.connect();
3784
+ this.client = client;
3785
+ return client;
3786
+ }
3787
+ async seed(sql) {
3788
+ await (await this.getClient()).query(sql);
3789
+ }
3790
+ async reset() {
3791
+ const client = await this.getClient();
3792
+ const result = await client.query(`
3793
+ SELECT tablename FROM pg_tables
3794
+ WHERE schemaname = '${this.schema}'
3795
+ AND tablename NOT LIKE '_prisma%'
3796
+ `);
3797
+ for (const row of result.rows) await client.query(`TRUNCATE "${this.schema}"."${row.tablename}" CASCADE`);
3798
+ }
3799
+ async query(table, columns) {
3800
+ const client = await this.getClient();
3801
+ const columnList = columns.join(", ");
3802
+ return (await client.query(`SELECT ${columnList} FROM "${this.schema}"."${table}" ORDER BY 1`)).rows.map((row) => columns.map((col) => row[col]));
3803
+ }
3804
+ isolation() {
3805
+ return {
3806
+ acquire: async (workerId) => {
3807
+ const workerSchema = `worker_${workerId}`;
3808
+ this.originalConnectionString = this.connectionString;
3809
+ const client = await this.getClient();
3810
+ await client.query(`DROP SCHEMA IF EXISTS "${workerSchema}" CASCADE`);
3811
+ await client.query(`CREATE SCHEMA "${workerSchema}"`);
3812
+ const tables = await client.query(`
3813
+ SELECT tablename FROM pg_tables
3814
+ WHERE schemaname = 'public'
3815
+ AND tablename NOT LIKE '_prisma%'
3816
+ `);
3817
+ for (const row of tables.rows) await client.query(`CREATE TABLE "${workerSchema}"."${row.tablename}" (LIKE "public"."${row.tablename}" INCLUDING ALL)`);
3818
+ this.schema = workerSchema;
3819
+ await client.query(`SET search_path TO "${workerSchema}", public`);
3820
+ const url = new URL(this.connectionString);
3821
+ url.searchParams.set("options", `-c search_path=${workerSchema},public`);
3822
+ this.connectionString = url.toString();
3823
+ },
3824
+ reset: async () => {
3825
+ await this.reset();
3826
+ },
3827
+ release: async () => {
3828
+ const client = await this.getClient();
3829
+ const workerSchema = this.schema;
3830
+ this.schema = "public";
3831
+ this.connectionString = this.originalConnectionString;
3832
+ await client.query(`SET search_path TO public`);
3833
+ await client.query(`DROP SCHEMA IF EXISTS "${workerSchema}" CASCADE`);
3834
+ }
3835
+ };
3836
+ }
3837
+ };
3838
+ /**
3839
+ * Create a PostgreSQL service handle.
3840
+ *
3841
+ * @example
3842
+ * // The record key is the name: it is the spec's `database` vocabulary, and
3843
+ * // Kebab-cased it is the folder the init script is read from.
3844
+ * // { db: postgres() } → docker/db/init.sql, then docker/postgres/init.sql
3845
+ * // { analyticsDb: postgres() } → docker/analytics-db/init.sql
3846
+ * const events = postgres();
3847
+ * // After start: events.connectionString is populated
3848
+ */
3849
+ function postgres(options = {}) {
3850
+ return new PostgresHandle(options);
3851
+ }
3852
+ //#endregion
3853
+ //#region src/seams/redis/redis.ts
3854
+ var RedisHandle = class {
3855
+ type = "redis";
3856
+ serviceName = null;
3857
+ defaultPort = 6379;
3858
+ defaultImage;
3859
+ environment = {};
3860
+ connectionString = "";
3861
+ started = false;
3862
+ dbIndex = 0;
3863
+ constructor(options = {}) {
3864
+ this.defaultImage = options.image ?? "redis:7";
3865
+ }
3866
+ buildConnectionString(host, port) {
3867
+ return `redis://${host}:${port}`;
3868
+ }
3869
+ createDatabaseAdapter() {
3870
+ return null;
3871
+ }
3872
+ async healthcheck() {
3873
+ if (!this.connectionString) throw new Error("redis: cannot healthcheck — no connection string");
3874
+ try {
3875
+ const { createClient } = await loadPeer("redis", "redis()", async () => await import("redis"));
3876
+ const client = createClient({ url: this.connectionString });
3877
+ await client.connect();
3878
+ await client.ping();
3879
+ await client.disconnect();
3880
+ } catch (error) {
3881
+ throw new Error(`redis healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
3882
+ }
3883
+ }
3884
+ async initialize() {}
3885
+ async reset() {
3886
+ const { createClient } = await loadPeer("redis", "redis()", async () => await import("redis"));
3887
+ const client = createClient({
3888
+ url: this.connectionString,
3889
+ database: this.dbIndex
3890
+ });
3891
+ await client.connect();
3892
+ try {
3893
+ await client.flushDb();
3894
+ } finally {
3895
+ await client.disconnect();
3896
+ }
3897
+ }
3898
+ isolation() {
3899
+ return {
3900
+ acquire: async (workerId) => {
3901
+ const numericId = Number.parseInt(workerId, 10) || 0;
3902
+ this.dbIndex = numericId % 15 + 1;
3903
+ },
3904
+ reset: async () => {
3905
+ await this.reset();
3906
+ },
3907
+ release: async () => {
3908
+ await this.reset();
3909
+ this.dbIndex = 0;
3910
+ }
3911
+ };
3912
+ }
3913
+ };
3914
+ /**
3915
+ * Create a Redis service handle.
3916
+ *
3917
+ * @example
3918
+ * // The record key is the name it reports under: { cache: redis() } → "cache".
3919
+ * const cache = redis();
3920
+ * // After start: cache.connectionString is populated
3921
+ */
3922
+ function redis(options = {}) {
3923
+ return new RedisHandle(options);
3924
+ }
3925
+ //#endregion
3926
+ //#region src/seams/sqlite/sqlite.ts
3414
3927
  const SQLITE_FILE_HEADER = Buffer.from("SQLite format 3\0");
3415
3928
  /**
3416
3929
  * Whether `path` looks like a usable SQLite database file: it exists, is at
@@ -3418,6 +3931,35 @@ const SQLITE_FILE_HEADER = Buffer.from("SQLite format 3\0");
3418
3931
  * bytes. Does not validate anything beyond the header — good enough to
3419
3932
  * reject a 0-byte or truncated leftover without opening the file.
3420
3933
  */
3934
+ /**
3935
+ * `better-sqlite3`'s constructor, loaded the first time a database is opened.
3936
+ *
3937
+ * The binding is an OPTIONAL peer: it is a native module to compile, and a
3938
+ * repository that never declares `sqlite()` has no use for one. Loading it
3939
+ * here also puts the pnpm build note where the failure happens.
3940
+ */
3941
+ let databaseConstructor = null;
3942
+ async function loadDatabase() {
3943
+ if (databaseConstructor === null) {
3944
+ const constructor = await loadPeer("better-sqlite3", "sqlite()", async () => {
3945
+ return (await import("better-sqlite3")).default;
3946
+ });
3947
+ requireBuiltPeer("better-sqlite3", "sqlite()", () => {
3948
+ new constructor(":memory:").close();
3949
+ });
3950
+ databaseConstructor = constructor;
3951
+ }
3952
+ return databaseConstructor;
3953
+ }
3954
+ /**
3955
+ * The constructor, once loaded. The read accessors (`seed`, `query`, `reset`)
3956
+ * are reached only AFTER `initialize()` has opened a database, so by the time
3957
+ * one of them runs the peer is in hand — which is what lets them stay sync.
3958
+ */
3959
+ function database() {
3960
+ if (databaseConstructor === null) throw new Error("sqlite(): the database is not open yet — `initialize()` loads the driver before any read.");
3961
+ return databaseConstructor;
3962
+ }
3421
3963
  function isValidSqliteTemplate(path) {
3422
3964
  if (!existsSync(path)) return false;
3423
3965
  let fd;
@@ -3506,7 +4048,7 @@ function sqliteTemplateName(options = {}) {
3506
4048
  * The directory a project caches its schema templates in —
3507
4049
  * `<root>/.artifacts/vitest/sqlite/`, created on demand.
3508
4050
  *
3509
- * A project, not a machine. The template used to live in the OS tmpdir, where
4051
+ * A project, not a machine. A template in the OS tmpdir is shared, where
3510
4052
  * two checkouts of the same repository shared one file: whichever ran first
3511
4053
  * built it, the other silently inherited that schema, and a branch that changed
3512
4054
  * the schema poisoned the branch beside it. A path under the project root
@@ -3574,7 +4116,7 @@ function removeDatabaseFiles(path) {
3574
4116
  }
3575
4117
  var SqliteHandle = class {
3576
4118
  type = "sqlite";
3577
- composeName = null;
4119
+ serviceName = null;
3578
4120
  defaultPort = 0;
3579
4121
  defaultImage = "";
3580
4122
  environment = {};
@@ -3612,7 +4154,8 @@ var SqliteHandle = class {
3612
4154
  * rather than proceeding into a second concurrent `prisma db push` on the
3613
4155
  * same file (which is what `database is locked` was).
3614
4156
  */
3615
- async initialize(_composeDir, root) {
4157
+ async initialize(_dockerDir, root) {
4158
+ await loadDatabase();
3616
4159
  const projectRoot = root ?? discoverRoot(process.cwd());
3617
4160
  this.templatePath = resolve(sqliteTemplateDir(projectRoot), sqliteTemplateName(this.options));
3618
4161
  const lockPath = `${this.templatePath}.lock`;
@@ -3656,15 +4199,15 @@ var SqliteHandle = class {
3656
4199
  },
3657
4200
  stdio: "pipe"
3658
4201
  });
3659
- const tmpDb = new Database(buildPath);
4202
+ const tmpDb = new (database())(buildPath);
3660
4203
  tmpDb.pragma("wal_checkpoint(TRUNCATE)");
3661
4204
  tmpDb.close();
3662
4205
  } else if (this.initSql) {
3663
4206
  const sql = readFileSync(this.initSql, "utf8");
3664
- const templateDb = new Database(buildPath);
4207
+ const templateDb = new (database())(buildPath);
3665
4208
  templateDb.exec(sql);
3666
4209
  templateDb.close();
3667
- } else new Database(buildPath).close();
4210
+ } else new (database())(buildPath).close();
3668
4211
  renameSync(buildPath, this.templatePath);
3669
4212
  } finally {
3670
4213
  removeDatabaseFiles(buildPath);
@@ -3673,7 +4216,7 @@ var SqliteHandle = class {
3673
4216
  getDb() {
3674
4217
  const dbPath = this.workerDbPath || this.templatePath;
3675
4218
  if (!this.db) {
3676
- this.db = new Database(dbPath);
4219
+ this.db = new (database())(dbPath);
3677
4220
  this.db.pragma("journal_mode = WAL");
3678
4221
  }
3679
4222
  return this.db;
@@ -3734,15 +4277,14 @@ function sqlite(options = {}) {
3734
4277
  }
3735
4278
  //#endregion
3736
4279
  //#region src/index.ts
3737
- registerContainerIntegrations({
3738
- createComposeStack: (composeFile, projectName) => new ComposeStackAdapter(composeFile, projectName),
3739
- createContainer: (options) => new TestcontainersAdapter(options),
3740
- parseComposeFile
4280
+ /**
4281
+ * The module-scope network double, on msw's node server — what a test with no
4282
+ * chain to hang contracts on reaches for (chapter 16).
4283
+ */
4284
+ const intercept = interceptThrough(async (contracts) => {
4285
+ const { registerContracts } = await import("./server.js");
4286
+ return await registerContracts(contracts);
3741
4287
  });
3742
- registerComposeServiceFactory("postgres", (service) => postgres({
3743
- composeService: service.name,
3744
- env: service.environment
3745
- }));
3746
- registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
4288
+ registerContainerIntegrations({ createContainer: (options) => new TestcontainersAdapter(options) });
3747
4289
  //#endregion
3748
- export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ResponseAccessor, ScreenResult, TableAccessor, TextAccessor, anthropic, banner, button, complementary, component, content, contentinfo, defineContract, defineContracts, dialog, disabled, enabled, field, findContainersByLabel, focused, form, heading, http, inspectContainer, link, listitem, main, match, mockOf, mockOfDate, navigation, openai, option, postgres, redis, region, removeContainers, row, search, selected, specification, sqlite, status, table, testId, text, valued, within };
4290
+ export { Matcher, ProcessHandle, anthropic, banner, button, clock, complementary, component, content, contentinfo, defineContract, defineContracts, dialog, disabled, enabled, field, focused, form, heading, http, intercept, link, listitem, main, match, mockOf, navigation, openai, option, postgres, processService as process, redis, region, required, row, search, selected, specification, sqlite, status, table, testId, text, valued, waitUntil, within };