@jterrazz/test 15.2.0 → 15.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,19 +1,20 @@
1
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";
2
+ 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";
3
+ 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 missingHint, u as formatResponseDiff, v as discoverRoot, w as diffDirectories, x as resolveRoot, y as expandWorkdir, z as TextAccessor } from "./artifacts.js";
4
4
  import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { parse } from "yaml";
7
7
  import { execFile, execSync, spawn, spawnSync } from "node:child_process";
8
8
  import { Client } from "pg";
9
- import { mockDeep } from "vitest-mock-extended";
9
+ import { mock, mockDeep } from "vitest-mock-extended";
10
10
  import MockDatePackage from "mockdate";
11
+ import { vi } from "vitest";
11
12
  import { tmpdir } from "node:os";
12
13
  import "node:url";
14
+ import { createHash, randomUUID } from "node:crypto";
13
15
  import { createServer } from "node:http";
14
16
  import { promisify } from "node:util";
15
17
  import Database from "better-sqlite3";
16
- import { createHash } from "node:crypto";
17
18
  //#region src/integrations/compose/compose-parser.ts
18
19
  /**
19
20
  * Parse a docker-compose file and extract service definitions.
@@ -110,6 +111,60 @@ var ComposeStackAdapter = class {
110
111
  }
111
112
  };
112
113
  //#endregion
114
+ //#region src/integrations/msw/scope.ts
115
+ /**
116
+ * The network double in MODULE scope — the same contracts, the same queue and
117
+ * the same strictness as a chain's `.intercept()`, for the one kind of test
118
+ * that has no chain to hang them on.
119
+ *
120
+ * A module test whose subject reaches the network had exactly two ways out
121
+ * before this: `vi.stubGlobal('fetch')`, which replaces the platform with a
122
+ * hand-written stand-in, or `msw/node` imported directly, which F6 refuses a
123
+ * test and F8 refuses a consumer to declare. Both answer "what did the outside
124
+ * world reply" in a dialect nothing else in the repository speaks.
125
+ *
126
+ * The scope is an `AsyncDisposable`, so the handlers come off at the end of
127
+ * the block however the block ends — and the strict violation (an outgoing
128
+ * request no contract accepted) is raised THERE, which is what makes the
129
+ * double a guard rather than a convenience.
130
+ */
131
+ /**
132
+ * The contracts a call declared, whichever of the two forms it used. Exported
133
+ * for its own module test: the bare-request refusal is a runtime guard the
134
+ * compiler already states, so no typed call site can reach it.
135
+ *
136
+ * @internal
137
+ */
138
+ function declared(requestOrContracts, maybeResponse) {
139
+ if (Array.isArray(requestOrContracts) || isContracts(requestOrContracts) || isContract(requestOrContracts)) return contractsOf(requestOrContracts);
140
+ 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).");
141
+ return [{
142
+ request: requestOrContracts,
143
+ response: maybeResponse
144
+ }];
145
+ }
146
+ /**
147
+ * Build the module-scope double on one engine. The node entry hands it msw's
148
+ * server, the page's entry hands it msw's worker: one shape, two transports,
149
+ * exactly as a chain's `.intercept()` already has it.
150
+ *
151
+ * @internal
152
+ */
153
+ function interceptThrough(register) {
154
+ async function intercept(requestOrContracts, maybeResponse) {
155
+ const contracts = declared(requestOrContracts, maybeResponse);
156
+ 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.");
157
+ const registration = await register(contracts);
158
+ return { [Symbol.asyncDispose]: async () => {
159
+ const violation = registration.violation();
160
+ registration.cleanup();
161
+ await Promise.resolve();
162
+ if (violation) throw violation;
163
+ } };
164
+ }
165
+ return intercept;
166
+ }
167
+ //#endregion
113
168
  //#region src/integrations/postgres/postgres.ts
114
169
  var PostgresHandle = class {
115
170
  type = "postgres";
@@ -641,6 +696,18 @@ function wrapJson(data) {
641
696
  };
642
697
  }
643
698
  const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
699
+ const STREAM_CONTENT_TYPE = "application/octet-stream";
700
+ const SSE_CONTENT_TYPE = "text/event-stream";
701
+ /** The wire form of one event — the frame an `EventSource` parses. */
702
+ function frameEvent(event) {
703
+ const lines = [];
704
+ if (event.event !== void 0) lines.push(`event: ${event.event}`);
705
+ if (event.id !== void 0) lines.push(`id: ${event.id}`);
706
+ if (event.retry !== void 0) lines.push(`retry: ${event.retry}`);
707
+ const payload = typeof event.data === "string" ? event.data : JSON.stringify(event.data);
708
+ for (const line of payload.split("\n")) lines.push(`data: ${line}`);
709
+ return `${lines.join("\n")}\n\n`;
710
+ }
644
711
  function matchesBody(body, expected) {
645
712
  if (typeof expected === "string") return (typeof body === "string" ? body : JSON.stringify(body ?? "")).includes(expected);
646
713
  if (expected instanceof RegExp) {
@@ -750,6 +817,52 @@ const http = {
750
817
  transport: "network-error"
751
818
  };
752
819
  },
820
+ /**
821
+ * Response: a body that arrives in PIECES, written in order.
822
+ *
823
+ * A subject that reads a response as it lands — a token-by-token render, a
824
+ * progress bar, a reconnect on a half-read body — is specified by the
825
+ * chunks and their order, which a single serialised body cannot state.
826
+ * Honoured by all three engines: msw's server, msw's worker, and the
827
+ * `node:http` stub the website and mobile facets serve from.
828
+ *
829
+ * @example
830
+ * http.stream(['Hel', 'lo, ', 'world'], { contentType: 'text/plain', delay: 10 })
831
+ */
832
+ stream(chunks, init) {
833
+ return {
834
+ status: init?.status ?? 200,
835
+ body: {
836
+ chunks: [...chunks],
837
+ contentType: init?.contentType ?? STREAM_CONTENT_TYPE,
838
+ delayBetweenChunks: init?.delay ?? 0,
839
+ kind: STREAM_BODY
840
+ },
841
+ headers: init?.headers
842
+ };
843
+ },
844
+ /**
845
+ * Response: a server-sent event stream — one chunk per event, framed the
846
+ * way an `EventSource` reads them, served as `text/event-stream`.
847
+ *
848
+ * @example
849
+ * http.sse([{ data: { token: 'Hel' } }, { data: { token: 'lo' } }, { event: 'done', data: '' }])
850
+ */
851
+ sse(events, init) {
852
+ return {
853
+ status: init?.status ?? 200,
854
+ body: {
855
+ chunks: events.map(frameEvent),
856
+ contentType: SSE_CONTENT_TYPE,
857
+ delayBetweenChunks: init?.delay ?? 0,
858
+ kind: STREAM_BODY
859
+ },
860
+ headers: {
861
+ "cache-control": "no-cache",
862
+ ...init?.headers
863
+ }
864
+ };
865
+ },
753
866
  /** Response: a text body, served as `text/plain` (200 by default). */
754
867
  text(body, init) {
755
868
  return {
@@ -922,11 +1035,9 @@ const openai = {
922
1035
  };
923
1036
  //#endregion
924
1037
  //#region src/vitest/mock-of.ts
925
- /**
926
- * Create a deep mock proxy for a given type.
927
- * Wraps `vitest-mock-extended`'s `mockDeep` for convenient port mocking.
928
- */
929
- const mockOf = mockDeep;
1038
+ function mockOf(options) {
1039
+ return options?.deep === false ? mock() : mockDeep();
1040
+ }
930
1041
  //#endregion
931
1042
  //#region src/vitest/mock-of-date.ts
932
1043
  /**
@@ -935,6 +1046,132 @@ const mockOf = mockDeep;
935
1046
  */
936
1047
  const mockOfDate = MockDatePackage;
937
1048
  //#endregion
1049
+ //#region src/vitest/clock.ts
1050
+ /** The instant `iso` names, or a refusal that says what an instant looks like. */
1051
+ function instantOf(method, iso) {
1052
+ const at = new Date(iso);
1053
+ 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').`);
1054
+ return at;
1055
+ }
1056
+ /**
1057
+ * One clock per test — a second one taken inside the first is refused rather
1058
+ * than nested.
1059
+ *
1060
+ * Disposing gives the REAL clock back, which is the only thing vitest's fake
1061
+ * timers can restore: the instant, the `toFake` set and the queue a previous
1062
+ * scope held are not readable, so an inner scope that ended would silently end
1063
+ * the outer one too. A refusal says which of the two the test meant.
1064
+ */
1065
+ function take(method, options) {
1066
+ 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.`);
1067
+ vi.useFakeTimers(options);
1068
+ return { [Symbol.dispose]: () => {
1069
+ vi.useRealTimers();
1070
+ } };
1071
+ }
1072
+ const clock = {
1073
+ /**
1074
+ * Move the pinned clock forward by `ms` — and, under {@link clock.run},
1075
+ * fire every callback that came due, awaiting what each of them started.
1076
+ *
1077
+ * Only a pinned clock moves: called with no clock taken, it says so rather
1078
+ * than silently doing nothing to the real one.
1079
+ */
1080
+ async advance(ms) {
1081
+ 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).");
1082
+ await vi.advanceTimersByTimeAsync(ms);
1083
+ },
1084
+ /**
1085
+ * Pin `Date` at `iso` for the current scope. `Date` ONLY: the scheduler
1086
+ * stays real, so a subject that awaits, renders or polls keeps running.
1087
+ *
1088
+ * @example
1089
+ * using _ = clock.at('2026-03-04T09:30:00Z');
1090
+ * expect(stampedAt()).toBe('2026-03-04T09:30:00.000Z');
1091
+ */
1092
+ at(iso) {
1093
+ return take("at", {
1094
+ now: instantOf("at", iso),
1095
+ toFake: ["Date"]
1096
+ });
1097
+ },
1098
+ /**
1099
+ * Take the scheduler as well as the calendar: `setTimeout`, `setInterval`
1100
+ * and their friends queue instead of firing, and {@link clock.advance} is
1101
+ * what makes them due. For a subject whose behaviour IS elapsed time — a
1102
+ * debounce, a retry backoff, a poll — where waiting for real would be the
1103
+ * arbitrary sleep J2 forbids.
1104
+ *
1105
+ * @example
1106
+ * using _ = clock.run('2026-03-04T09:30:00Z');
1107
+ * const pending = retrying(failsTwice);
1108
+ * await clock.advance(5_000);
1109
+ * await expect(pending).resolves.toBe('ok');
1110
+ */
1111
+ run(iso) {
1112
+ return take("run", iso === void 0 ? {} : { now: instantOf("run", iso) });
1113
+ }
1114
+ };
1115
+ //#endregion
1116
+ //#region src/specification/assertions/required.ts
1117
+ /**
1118
+ * The value, or a failure that says what was missing and why it mattered.
1119
+ *
1120
+ * A test reaching into a captured structure — a row, a header, the third
1121
+ * element of a list — meets `T | undefined` and has to do something about it.
1122
+ * The two things it usually does are both wrong: `!` erases the question, and
1123
+ * an `if (!x) throw new Error('missing')` re-states the same three lines in
1124
+ * every repository (the estate had two hand-written copies of exactly this).
1125
+ * `required` is that gesture with the reason attached, so the failure reads as
1126
+ * a sentence rather than as a `TypeError` two frames further down.
1127
+ *
1128
+ * @example
1129
+ * const id = required(result.response.body.id, 'the creation reply carries the new id');
1130
+ */
1131
+ function required(value, why) {
1132
+ 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.`);
1133
+ return value;
1134
+ }
1135
+ //#endregion
1136
+ //#region src/specification/assertions/wait-until.ts
1137
+ const DEFAULT_TIMEOUT_MS = 5e3;
1138
+ const DEFAULT_INTERVAL_MS = 50;
1139
+ /**
1140
+ * Wait until a condition holds — the sanctioned answer to "the thing I am
1141
+ * specifying is not ready yet".
1142
+ *
1143
+ * A spec that sleeps for a guessed duration is a spec that is slow on a fast
1144
+ * machine and flaky on a slow one, which is why J2 refuses `setTimeout` under
1145
+ * `specs/`. Every facet that drives something already waits on a CONDITION —
1146
+ * a served page, a banner on stdout, an element the visitor can see — and this
1147
+ * is that primitive where no facet owns the waiting: a background write to
1148
+ * land, a queue to drain, a file to appear.
1149
+ *
1150
+ * The failure names the condition and how long it was given, so a timeout is a
1151
+ * sentence rather than "expected true, got false".
1152
+ *
1153
+ * The budget is measured on `performance.now()`, not on `Date` — `clock.at()`
1154
+ * freezes the calendar, and a deadline read off a frozen calendar never
1155
+ * arrives: the poll would run until vitest killed the test with a sentence
1156
+ * about the file rather than about the condition.
1157
+ *
1158
+ * Not for a subject under `clock.run()`: there the scheduler is the test's, and
1159
+ * `clock.advance(ms)` is what makes time pass.
1160
+ *
1161
+ * @example
1162
+ * await waitUntil(() => outbox.length === 1, { why: 'the job wrote its one message' });
1163
+ */
1164
+ async function waitUntil(predicate, options = {}) {
1165
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
1166
+ const interval = options.interval ?? DEFAULT_INTERVAL_MS;
1167
+ const deadline = performance.now() + timeout;
1168
+ for (;;) {
1169
+ if (await predicate()) return;
1170
+ 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.`);
1171
+ await pause(interval);
1172
+ }
1173
+ }
1174
+ //#endregion
938
1175
  //#region src/integrations/hono/hono.adapter.ts
939
1176
  /**
940
1177
  * Server adapter that dispatches requests in-process through a Hono app instance.
@@ -1210,7 +1447,7 @@ function matchStreamFile(accessor, name, frozen) {
1210
1447
  writeFileSync(filePath, merged);
1211
1448
  return PASS(`updated ${GROUND_EXPECTED}/${name}`);
1212
1449
  }
1213
- if (!existsSync(filePath)) return FAIL(`${accessor.streamName} fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
1450
+ if (!existsSync(filePath)) return FAIL(`${accessor.streamName} fixture "${name}" does not exist at ${filePath}.\n${missingHint(frozen)}`);
1214
1451
  return compareStreamText(accessor, name, readFileSync(filePath, "utf8"));
1215
1452
  }
1216
1453
  function matchJsonFile(accessor, name, frozen) {
@@ -1223,7 +1460,7 @@ function matchJsonFile(accessor, name, frozen) {
1223
1460
  writeFileSync(filePath, formatJson(merged));
1224
1461
  return PASS(`updated ${GROUND_EXPECTED}/${name}`);
1225
1462
  }
1226
- if (!existsSync(filePath)) return FAIL(`JSON fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
1463
+ if (!existsSync(filePath)) return FAIL(`JSON fixture "${name}" does not exist at ${filePath}.\n${missingHint(frozen)}`);
1227
1464
  const expected = JSON.parse(readFileSync(filePath, "utf8"));
1228
1465
  if (structuralEquals(expected, actual, accessor.captures)) return PASS(`expected JSON not to match ${GROUND_EXPECTED}/${name}`);
1229
1466
  return FAIL(formatResponseDiff(name, renderExpected(expected), actual));
@@ -1279,7 +1516,7 @@ function matchResponseFile(accessor, name, frozen) {
1279
1516
  writeFileSync(filePath, serializeResponseFile(updated));
1280
1517
  return PASS(`updated ${GROUND_EXPECTED}/${name}`);
1281
1518
  }
1282
- if (!existsSync(filePath)) return FAIL(`Response fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
1519
+ if (!existsSync(filePath)) return FAIL(`Response fixture "${name}" does not exist at ${filePath}.\n${missingHint(frozen)}`);
1283
1520
  const failure = compareResponse(name, parseResponseFile(readFileSync(filePath, "utf8"), `${GROUND_EXPECTED}/${name}`), accessor, accessor.captures);
1284
1521
  return failure === null ? PASS(`expected response not to match ${GROUND_EXPECTED}/${name}`) : FAIL(failure);
1285
1522
  }
@@ -1302,7 +1539,7 @@ async function matchTreeFile(actualRoot, testDir, name, scope, frozen) {
1302
1539
  }
1303
1540
  return PASS(`updated ${GROUND_EXPECTED}/${name}/`);
1304
1541
  }
1305
- if (!existsSync(fixtureDir)) return FAIL(`Directory fixture "${name}" does not exist at ${fixtureDir}.\n${UPDATE_HINT}`);
1542
+ if (!existsSync(fixtureDir)) return FAIL(`Directory fixture "${name}" does not exist at ${fixtureDir}.\n${missingHint(frozen)}`);
1306
1543
  const diff = await diffDirectories(fixtureDir, actualRoot, { scope });
1307
1544
  if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return PASS(`expected directory not to match ${GROUND_EXPECTED}/${name}/`);
1308
1545
  return FAIL(formatDirectoryDiff(name, diff, "Run with TEST_UPDATE=1 to update the fixture."));
@@ -1347,8 +1584,15 @@ function toContain(received, expected) {
1347
1584
  if (received instanceof TextAccessor) return textContains(received, String(expected));
1348
1585
  return nativeContain(received, expected);
1349
1586
  }
1587
+ /** Is this the shape, before the adapter is asked to build SQL from it? */
1588
+ function isRowsExpectation(expected) {
1589
+ if (expected === null || typeof expected !== "object") return false;
1590
+ const { columns, rows } = expected;
1591
+ return Array.isArray(columns) && Array.isArray(rows);
1592
+ }
1350
1593
  async function toMatchRows(received, expected) {
1351
1594
  if (!(received instanceof TableAccessor)) throw new TypeError("toMatchRows: unsupported subject — expected result.table(...).");
1595
+ 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
1596
  const actual = await received.query(expected.columns);
1353
1597
  const pass = actual.length === expected.rows.length && expected.rows.every((row, i) => {
1354
1598
  const actualRow = actual[i];
@@ -1423,6 +1667,61 @@ var HttpResult = class extends BaseResult {
1423
1667
  }
1424
1668
  };
1425
1669
  //#endregion
1670
+ //#region src/specification/facets/integration/result.ts
1671
+ /**
1672
+ * The result of an in-process call — the integration facet's whole surface.
1673
+ *
1674
+ * Two readings, never both: `value` is what the call RETURNED, `error` is
1675
+ * what it THREW. A module that refuses is specified the same way a module
1676
+ * that answers is, and neither needs a `try`/`catch` in the test — which is
1677
+ * the shape that used to make a refusal spec three times the size of the
1678
+ * happy one, and the shape that quietly passes when nothing throws at all.
1679
+ *
1680
+ * Both are the package's ordinary accessors, so the golden mechanism reaches
1681
+ * them, and `Returned` is what `.call<T>()` returned — `result.value.value` is
1682
+ * that type, not `unknown`, so a one-field reading needs no golden file: `toMatch('<name>.json'|'<name>.txt')`, the `{{token}}` grammar,
1683
+ * `{ frozen }` and `TEST_UPDATE=1` all work here exactly as on an HTTP
1684
+ * response or a command's stdout.
1685
+ */
1686
+ var CallResult = class extends BaseResult {
1687
+ outcome;
1688
+ constructor(options) {
1689
+ super(options);
1690
+ this.outcome = options.outcome;
1691
+ }
1692
+ /**
1693
+ * What the call THREW, as text — the message of an `Error`, the value
1694
+ * itself when something else was thrown, and empty when it returned.
1695
+ *
1696
+ * Empty rather than absent on purpose: `await expect(result.error).toBeEmpty()`
1697
+ * is how a spec says "and it did not refuse" — awaited, because the
1698
+ * matcher answers a promise on every subject it takes, and a dropped one
1699
+ * passes the test while the failure surfaces as an unhandled rejection
1700
+ * (D2 refuses the bare form).
1701
+ */
1702
+ get error() {
1703
+ return new TextAccessor(describeError(this.outcome), "error", this.testDir, { captures: this.captures });
1704
+ }
1705
+ /**
1706
+ * What the call RETURNED. A string reads as a stream (`<name>.txt`),
1707
+ * anything else as JSON (`<name>.json`) — the same two subjects every
1708
+ * other facet's results are built from.
1709
+ *
1710
+ * A call that threw returns the empty value: what it produced is
1711
+ * {@link CallResult.error}.
1712
+ */
1713
+ get value() {
1714
+ const produced = this.outcome.threw ? null : this.outcome.value;
1715
+ 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);
1716
+ }
1717
+ };
1718
+ /** The thrown thing, as the text a golden compares. */
1719
+ function describeError(outcome) {
1720
+ if (!outcome.threw) return "";
1721
+ const { error } = outcome;
1722
+ return error instanceof Error ? error.message : String(error);
1723
+ }
1724
+ //#endregion
1426
1725
  //#region src/specification/facets/mobile/result.ts
1427
1726
  /** Result from a `.open()` action — the screen as the device saw it, final state. */
1428
1727
  var ScreenResult = class extends BaseResult {
@@ -1713,6 +2012,7 @@ function getCallerDir() {
1713
2012
  * only surfaces the methods that make sense for it.
1714
2013
  */
1715
2014
  var SpecificationBuilder = class {
2015
+ pinnedClock = null;
1716
2016
  commandEnv = {};
1717
2017
  config;
1718
2018
  contracts = [];
@@ -1725,6 +2025,22 @@ var SpecificationBuilder = class {
1725
2025
  this.testDir = testDir;
1726
2026
  }
1727
2027
  /**
2028
+ * Pin the clock at `iso` for this chain.
2029
+ *
2030
+ * In-process facets (api, jobs, integration) pin the runner's own `Date`,
2031
+ * which is the app's; a website visit pins the PAGE's, through the browser.
2032
+ * Either way the clock is released when the action resolves, so a chain
2033
+ * never leaks its instant into the next one (CONVENTIONS D16).
2034
+ *
2035
+ * @example
2036
+ * const result = await api.clock('2026-03-04T09:30:00Z').get('/now');
2037
+ */
2038
+ clock(iso) {
2039
+ if (this.config.clockDisabledReason !== void 0) throw new Error(`.clock(): ${this.config.clockDisabledReason}`);
2040
+ this.pinnedClock = iso;
2041
+ return this;
2042
+ }
2043
+ /**
1728
2044
  * Queue a SQL seed file to run before the action.
1729
2045
  *
1730
2046
  * With two or more declared databases the `database` option is mandatory;
@@ -1925,6 +2241,7 @@ var SpecificationBuilder = class {
1925
2241
  * expect(result.body).toMatch('robots.txt');
1926
2242
  */
1927
2243
  async fetch(path) {
2244
+ 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
2245
  return await this.executeSetup(null, async () => await this.runFetchAction(path));
1929
2246
  }
1930
2247
  /**
@@ -1964,6 +2281,22 @@ var SpecificationBuilder = class {
1964
2281
  return await this.executeSetup(null, async () => await this.runOpenAction(deepLink, scenario));
1965
2282
  }
1966
2283
  /**
2284
+ * Call the module under test and resolve with what it produced — the
2285
+ * value it returned, or the error it threw.
2286
+ *
2287
+ * A refusal is a reading, never a `try`/`catch`: `result.error` carries
2288
+ * what was thrown, and `result.value` what was returned. That is what
2289
+ * keeps a spec of a refusal the same size as a spec of a success, and
2290
+ * what stops a "it should throw" spec from passing when nothing does.
2291
+ *
2292
+ * @example
2293
+ * const result = await integration.seed('rows.sql').call(({ db }) => find(db.connectionString, id));
2294
+ * expect(result.value).toMatch('found.json');
2295
+ */
2296
+ async call(subject) {
2297
+ return await this.executeSetup(null, async () => await this.runCallAction(subject));
2298
+ }
2299
+ /**
1967
2300
  * Execute the named job registered via the `jobs` option of
1968
2301
  * `specification.jobs()` and resolve with the result.
1969
2302
  *
@@ -2007,6 +2340,8 @@ var SpecificationBuilder = class {
2007
2340
  const { registerContracts } = await import("./intercept.js");
2008
2341
  registration = await registerContracts(this.contracts);
2009
2342
  }
2343
+ const instant = this.pinnedClock;
2344
+ const pinned = instant !== null && this.config.baseUrl === void 0 ? clock.at(instant) : null;
2010
2345
  try {
2011
2346
  const value = await action();
2012
2347
  const violation = registration?.violation() ?? this.config.backend?.violation();
@@ -2015,6 +2350,7 @@ var SpecificationBuilder = class {
2015
2350
  } catch (error) {
2016
2351
  throw registration?.violation() ?? this.config.backend?.violation() ?? error;
2017
2352
  } finally {
2353
+ pinned?.[Symbol.dispose]();
2018
2354
  registration?.cleanup();
2019
2355
  }
2020
2356
  }
@@ -2025,6 +2361,28 @@ var SpecificationBuilder = class {
2025
2361
  prepareWorkDir() {
2026
2362
  return mkdtempSync(resolve(tmpdir(), "spec-command-"));
2027
2363
  }
2364
+ async runCallAction(subject) {
2365
+ const services = this.config.services ?? {};
2366
+ let outcome;
2367
+ try {
2368
+ outcome = {
2369
+ error: null,
2370
+ threw: false,
2371
+ value: await subject(services)
2372
+ };
2373
+ } catch (error) {
2374
+ outcome = {
2375
+ error,
2376
+ threw: true,
2377
+ value: null
2378
+ };
2379
+ }
2380
+ return new CallResult({
2381
+ config: this.config,
2382
+ outcome,
2383
+ testDir: this.testDir
2384
+ });
2385
+ }
2028
2386
  async runHttpAction(request) {
2029
2387
  if (!this.config.server) throw new Error("HTTP actions require a server adapter (use specification.api())");
2030
2388
  let { body, method, path } = request;
@@ -2076,6 +2434,7 @@ var SpecificationBuilder = class {
2076
2434
  const page = await browser.open(`${baseUrl}${path}`, {
2077
2435
  allowedOrigins: this.config.backendUrl ? [new URL(this.config.backendUrl).origin] : void 0,
2078
2436
  baseUrl,
2437
+ ...this.pinnedClock === null ? {} : { clock: this.pinnedClock },
2079
2438
  external: this.config.external ?? "allow",
2080
2439
  headers,
2081
2440
  scenario
@@ -2145,7 +2504,11 @@ var SpecificationBuilder = class {
2145
2504
  * the chain's own `.env()` — which always wins, `null` unsetting.
2146
2505
  */
2147
2506
  childEnv(workDir) {
2148
- let env = this.serviceEnv();
2507
+ const declared = this.config.defaultEnv;
2508
+ let env = declared ? {
2509
+ ...expandWorkdir(declared, workDir),
2510
+ ...this.serviceEnv()
2511
+ } : this.serviceEnv();
2149
2512
  const { dockerConfig } = this.config;
2150
2513
  if (dockerConfig && this.config.dockerTestRunId) env = {
2151
2514
  ...env,
@@ -2221,6 +2584,7 @@ function withDockerTestRunId(config) {
2221
2584
  function createApiFacet(config) {
2222
2585
  const start = () => new SpecificationBuilder(config, getCallerDir());
2223
2586
  return {
2587
+ clock: (iso) => start().clock(iso),
2224
2588
  delete: async (path) => await start().delete(path),
2225
2589
  get: async (path) => await start().get(path),
2226
2590
  headers: (headers) => start().headers(headers),
@@ -2237,17 +2601,31 @@ function createApiFacet(config) {
2237
2601
  function createJobsFacet(config) {
2238
2602
  const start = () => new SpecificationBuilder(config, getCallerDir());
2239
2603
  return {
2604
+ clock: (iso) => start().clock(iso),
2240
2605
  intercept: interceptOn(start),
2241
2606
  seed: (file, options) => start().seed(file, options),
2242
2607
  trigger: async (name) => await start().trigger(name)
2243
2608
  };
2244
2609
  }
2245
2610
  /**
2611
+ * Create the `integration` facet bound to the given adapter configuration.
2612
+ */
2613
+ function createIntegrationFacet(config) {
2614
+ const start = () => new SpecificationBuilder(config, getCallerDir());
2615
+ return {
2616
+ call: async (subject) => await start().call(subject),
2617
+ clock: (iso) => start().clock(iso),
2618
+ intercept: interceptOn(start),
2619
+ seed: (file, options) => start().seed(file, options)
2620
+ };
2621
+ }
2622
+ /**
2246
2623
  * Create the `website` facet bound to the given adapter configuration.
2247
2624
  */
2248
2625
  function createWebsiteFacet(config) {
2249
2626
  const start = () => new SpecificationBuilder(config, getCallerDir());
2250
2627
  return {
2628
+ clock: (iso) => start().clock(iso),
2251
2629
  fetch: async (path) => await start().fetch(path),
2252
2630
  headers: (headers) => start().headers(headers),
2253
2631
  intercept: interceptOn(start),
@@ -2561,6 +2939,124 @@ var Orchestrator = class {
2561
2939
  }
2562
2940
  };
2563
2941
  //#endregion
2942
+ //#region src/specification/facets/_common/process.ts
2943
+ /**
2944
+ * An external process, declared like any other service.
2945
+ *
2946
+ * Before this there were three shapes for the same thing: `website({ server })`
2947
+ * took a `ServeOptions`, a literate document took a `serve:` entry, and a
2948
+ * project that needed a second process — an API behind the site, Metro behind
2949
+ * the simulator — started one by hand in a `beforeAll` and killed it in an
2950
+ * `afterAll` it sometimes forgot. `process()` is the one shape, and the
2951
+ * framework owns the lifetime.
2952
+ *
2953
+ * It sits in a `services` record beside `postgres()`, `redis()` and
2954
+ * `sqlite()`, so it starts after them and can be handed their connection
2955
+ * strings through `env`, and it is stopped with the specification.
2956
+ */
2957
+ var ProcessHandle = class {
2958
+ /** Embedded: nothing to pull, nothing to run in a container. */
2959
+ defaultImage = "";
2960
+ defaultPort = 0;
2961
+ environment = {};
2962
+ type = "process";
2963
+ composeName = null;
2964
+ connectionString = "";
2965
+ started = false;
2966
+ adapter = null;
2967
+ options;
2968
+ constructor(options) {
2969
+ this.options = options;
2970
+ }
2971
+ buildConnectionString(host, port) {
2972
+ return `http://${host}:${port}`;
2973
+ }
2974
+ createDatabaseAdapter() {
2975
+ return null;
2976
+ }
2977
+ async healthcheck() {
2978
+ await Promise.resolve();
2979
+ }
2980
+ /**
2981
+ * A process is not a container, so the orchestrator never starts it: the
2982
+ * facet does, once its siblings are up and their connection strings are
2983
+ * readable ({@link startProcessServices}).
2984
+ */
2985
+ async initialize() {
2986
+ await Promise.resolve();
2987
+ }
2988
+ /**
2989
+ * Isolation is the RUN's, not the worker's — and a run here is one
2990
+ * EVALUATION of the specification module. Under vitest's default
2991
+ * isolation that is once per test file, so two files that import the same
2992
+ * specification get a child each, with the id the facet minted for it.
2993
+ * Nothing is acquired or reset between tests: the id is what tells two
2994
+ * runs apart, never a label a spec samples.
2995
+ */
2996
+ isolation() {
2997
+ return {
2998
+ acquire: async () => {
2999
+ await Promise.resolve();
3000
+ },
3001
+ release: async () => {
3002
+ await Promise.resolve();
3003
+ },
3004
+ reset: async () => {
3005
+ await Promise.resolve();
3006
+ }
3007
+ };
3008
+ }
3009
+ async reset() {
3010
+ await Promise.resolve();
3011
+ }
3012
+ /**
3013
+ * Spawn it and wait until it answers. `services` is every handle declared
3014
+ * beside this one, already started — what an `env` function reads.
3015
+ *
3016
+ * @internal
3017
+ */
3018
+ async spawnWith(root, services, runId) {
3019
+ const stated = typeof this.options.env === "function" ? this.options.env(services) : this.options.env;
3020
+ this.adapter = new ServeAdapter(this.options, root, "process", {
3021
+ ...stated,
3022
+ TEST_RUN_ID: runId
3023
+ });
3024
+ this.connectionString = await this.adapter.start();
3025
+ this.started = true;
3026
+ }
3027
+ /** Terminate the process group. Idempotent. */
3028
+ async shutdown() {
3029
+ await this.adapter?.stop();
3030
+ this.adapter = null;
3031
+ this.started = false;
3032
+ }
3033
+ /** What it was declared as — what a facet reads to start it in a slot of its own. */
3034
+ get spec() {
3035
+ return this.options;
3036
+ }
3037
+ /** The base URL it came up on — known only once it is started. */
3038
+ get url() {
3039
+ 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.");
3040
+ return this.connectionString;
3041
+ }
3042
+ };
3043
+ /**
3044
+ * Declare an external process the framework owns for the life of the
3045
+ * specification.
3046
+ *
3047
+ * @example
3048
+ * const { website, cleanup } = await specification.website({
3049
+ * services: { api: process({ command: 'bin/server web', ready: '/health' }) },
3050
+ * server: ({ api }) => process({
3051
+ * command: 'next dev',
3052
+ * env: { NEXT_PUBLIC_API_URL: api.connectionString },
3053
+ * }),
3054
+ * });
3055
+ */
3056
+ function processService(options) {
3057
+ return new ProcessHandle(options);
3058
+ }
3059
+ //#endregion
2564
3060
  //#region src/specification/facets/_common/services.ts
2565
3061
  function getWorkerId() {
2566
3062
  return process.env.VITEST_POOL_ID ?? "0";
@@ -2575,20 +3071,64 @@ async function releaseIsolation(services) {
2575
3071
  function declaredDatabaseKeys(services) {
2576
3072
  return Object.entries(services).filter(([, service]) => service.createDatabaseAdapter() !== null).map(([key]) => key);
2577
3073
  }
3074
+ /**
3075
+ * A process is not a container: the orchestrator never sees one, and the
3076
+ * facet starts it AFTER the rest, so an `env` function reads connection
3077
+ * strings that are already there.
3078
+ */
3079
+ function splitProcesses(services) {
3080
+ const infrastructure = {};
3081
+ const processes = [];
3082
+ for (const [key, handle] of Object.entries(services)) if (handle instanceof ProcessHandle) processes.push([key, handle]);
3083
+ else infrastructure[key] = handle;
3084
+ return {
3085
+ infrastructure,
3086
+ processes
3087
+ };
3088
+ }
3089
+ /**
3090
+ * Start every declared process, in declaration order, once the infrastructure
3091
+ * is up — so `env: ({ db }) => …` reads a connection string that exists. Each
3092
+ * carries the run's id, minted here: a label a spec would otherwise sample
3093
+ * from `Date.now()` and put under an oracle (CONVENTIONS D16, P7).
3094
+ *
3095
+ * @internal
3096
+ */
3097
+ async function startProcessServices(services, root, runId) {
3098
+ const { processes } = splitProcesses(services);
3099
+ const started = [];
3100
+ const stop = async () => {
3101
+ for (const handle of started.toReversed()) await handle.shutdown();
3102
+ started.length = 0;
3103
+ };
3104
+ for (const [, handle] of processes) try {
3105
+ await handle.spawnWith(root, services, runId);
3106
+ started.push(handle);
3107
+ } catch (error) {
3108
+ await stop();
3109
+ throw error;
3110
+ }
3111
+ return stop;
3112
+ }
2578
3113
  /** Start a services record via testcontainers and acquire worker isolation. */
2579
3114
  async function startServices(services, root) {
3115
+ const { infrastructure } = splitProcesses(services);
2580
3116
  const orchestrator = new Orchestrator({
2581
3117
  mode: "integration",
2582
3118
  root,
2583
- services
3119
+ services: infrastructure
2584
3120
  });
2585
3121
  await orchestrator.start();
2586
- await acquireIsolation(services);
3122
+ await acquireIsolation(infrastructure);
3123
+ const runId = randomUUID();
3124
+ const stopProcesses = await startProcessServices(services, root, runId);
2587
3125
  const databases = orchestrator.getDatabases();
2588
3126
  return {
2589
3127
  database: orchestrator.getDatabase() ?? void 0,
2590
3128
  databases: databases.size > 0 ? databases : void 0,
2591
- orchestrator
3129
+ orchestrator,
3130
+ runId,
3131
+ stopProcesses
2592
3132
  };
2593
3133
  }
2594
3134
  //#endregion
@@ -2678,7 +3218,7 @@ async function startApi(options) {
2678
3218
  const databaseKeys = declaredDatabaseKeys(services);
2679
3219
  if (mode === "node") {
2680
3220
  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);
3221
+ const { database, databases, orchestrator, stopProcesses } = await startServices(services, root);
2682
3222
  return {
2683
3223
  api: createApiFacet({
2684
3224
  database,
@@ -2687,6 +3227,7 @@ async function startApi(options) {
2687
3227
  server: new HonoAdapter(options.server(services))
2688
3228
  }),
2689
3229
  cleanup: async () => {
3230
+ await stopProcesses();
2690
3231
  await releaseIsolation(services);
2691
3232
  await orchestrator.stop();
2692
3233
  },
@@ -2709,6 +3250,7 @@ async function startApi(options) {
2709
3250
  database: orchestrator.getDatabase() ?? void 0,
2710
3251
  databaseKeys,
2711
3252
  databases: databases.size > 0 ? databases : void 0,
3253
+ clockDisabledReason: "the calendar it pins is this runner's, and compose mode runs the app in a container — assert the stamp with a `{{iso8601}}` token instead.",
2712
3254
  interceptDisabledReason: "intercepts are in-process (MSW) and not available in compose mode — keep intercept specs in node-only vitest projects.",
2713
3255
  server: new FetchAdapter(appUrl)
2714
3256
  }),
@@ -2887,6 +3429,7 @@ async function startCli(bin, options = {}) {
2887
3429
  if (Object.keys(services).length > 0) started = await startServices(services, root);
2888
3430
  return {
2889
3431
  cleanup: async () => {
3432
+ await started?.stopProcesses();
2890
3433
  await releaseIsolation(services);
2891
3434
  if (started) await started.orchestrator.stop();
2892
3435
  },
@@ -2896,6 +3439,7 @@ async function startCli(bin, options = {}) {
2896
3439
  databaseKeys,
2897
3440
  databases: started?.databases,
2898
3441
  dockerConfig: options.docker,
3442
+ defaultEnv: options.defaults,
2899
3443
  envSets: options.env,
2900
3444
  root,
2901
3445
  serveRegistry: options.serve,
@@ -2907,6 +3451,42 @@ async function startCli(bin, options = {}) {
2907
3451
  };
2908
3452
  }
2909
3453
  //#endregion
3454
+ //#region src/specification/facets/integration/start-integration.ts
3455
+ /**
3456
+ * Specify a module the way the assembled product is specified: against real
3457
+ * services and declared contracts, with the golden mechanism on what it
3458
+ * produces.
3459
+ *
3460
+ * The fork is the SUBJECT. A module alone is a module test, beside its code,
3461
+ * with no runner at all. A module that needs a real database, or whose oracle
3462
+ * is a golden file, is this — and it used to be neither: a `specs/` folder
3463
+ * with plain vitest, a hand-built sqlite template, a `beforeAll` that started
3464
+ * a container, and a `toMatchSnapshot` where the package has a golden engine.
3465
+ */
3466
+ async function startIntegration(options = {}) {
3467
+ const callerDir = getCallerDir();
3468
+ await registerMatchers();
3469
+ const root = resolveRoot(options.root, callerDir);
3470
+ const services = options.services ?? {};
3471
+ const databaseKeys = declaredDatabaseKeys(services);
3472
+ let started = null;
3473
+ if (Object.keys(services).length > 0) started = await startServices(services, root);
3474
+ return {
3475
+ cleanup: async () => {
3476
+ await started?.stopProcesses();
3477
+ await releaseIsolation(services);
3478
+ if (started) await started.orchestrator.stop();
3479
+ },
3480
+ integration: createIntegrationFacet({
3481
+ database: started?.database,
3482
+ databaseKeys,
3483
+ databases: started?.databases,
3484
+ root,
3485
+ services
3486
+ })
3487
+ };
3488
+ }
3489
+ //#endregion
2910
3490
  //#region src/specification/facets/jobs/start-jobs.ts
2911
3491
  async function startJobs(options) {
2912
3492
  const callerDir = getCallerDir();
@@ -2919,6 +3499,7 @@ async function startJobs(options) {
2919
3499
  const jobHandles = typeof options.jobs === "function" ? options.jobs(services) : options.jobs;
2920
3500
  return {
2921
3501
  cleanup: async () => {
3502
+ await started?.stopProcesses();
2922
3503
  await releaseIsolation(services);
2923
3504
  if (started) await started.orchestrator.stop();
2924
3505
  },
@@ -3067,6 +3648,22 @@ var StubBackend = class {
3067
3648
  if (reply.delay) await new Promise((resolve) => setTimeout(resolve, reply.delay));
3068
3649
  const status = reply.status ?? 200;
3069
3650
  const { body } = reply;
3651
+ if (isStreamBody(body)) {
3652
+ response.writeHead(status, {
3653
+ ...cors,
3654
+ "content-type": body.contentType,
3655
+ ...reply.headers
3656
+ });
3657
+ await toReadableStream(body).pipeTo(new WritableStream({
3658
+ close() {
3659
+ response.end();
3660
+ },
3661
+ write(chunk) {
3662
+ response.write(chunk);
3663
+ }
3664
+ }));
3665
+ return;
3666
+ }
3070
3667
  if (body === null || body === void 0) {
3071
3668
  response.writeHead(status, {
3072
3669
  ...cors,
@@ -3231,6 +3828,14 @@ async function startMobile(options) {
3231
3828
  const udid = options.device.udid ?? await resolveSimulatorUdid(options.device);
3232
3829
  await ensureBooted(udid);
3233
3830
  const appium = await startAppiumServer(root);
3831
+ const services = options.services ?? {};
3832
+ let started = null;
3833
+ if (Object.keys(services).length > 0) try {
3834
+ started = await startServices(services, root);
3835
+ } catch (error) {
3836
+ await appium.stop();
3837
+ throw error;
3838
+ }
3234
3839
  let backend = null;
3235
3840
  let backendUrl;
3236
3841
  if (options.backend) {
@@ -3262,6 +3867,11 @@ async function startMobile(options) {
3262
3867
  await device.close();
3263
3868
  device = null;
3264
3869
  }
3870
+ if (started) {
3871
+ await started.stopProcesses();
3872
+ await releaseIsolation(services);
3873
+ await started.orchestrator.stop();
3874
+ }
3265
3875
  await appium.stop();
3266
3876
  if (backend) await backend.stop();
3267
3877
  },
@@ -3271,6 +3881,66 @@ async function startMobile(options) {
3271
3881
  }
3272
3882
  //#endregion
3273
3883
  //#region src/specification/facets/website/start-website.ts
3884
+ /**
3885
+ * The declared shape, whichever of the three forms stated it — with an `env`
3886
+ * function resolved against the services that are already up, so the site is
3887
+ * handed the URL of the API it was started beside.
3888
+ */
3889
+ function resolveServer(server, services) {
3890
+ const stated = typeof server === "function" ? server(services) : server;
3891
+ const spec = stated instanceof ProcessHandle ? stated.spec : stated;
3892
+ if (typeof spec.env !== "function") return spec;
3893
+ return {
3894
+ ...spec,
3895
+ env: spec.env(services)
3896
+ };
3897
+ }
3898
+ /**
3899
+ * Start the declared record, if there is one. A record that fails to come up
3900
+ * must not orphan the stub that was started before it.
3901
+ */
3902
+ async function startDeclared(services, root, backend) {
3903
+ if (Object.keys(services).length === 0) return null;
3904
+ try {
3905
+ return await startServices(services, root);
3906
+ } catch (error) {
3907
+ await backend?.stop();
3908
+ throw error;
3909
+ }
3910
+ }
3911
+ /**
3912
+ * Start the site, or take the URL of a deployed one. A server that never came
3913
+ * up must not orphan the stub, nor the services it was started beside.
3914
+ */
3915
+ async function startSite(options, context) {
3916
+ if (!options.server) return {
3917
+ baseUrl: (options.url ?? "").replace(/\/$/u, ""),
3918
+ serve: null
3919
+ };
3920
+ const serve = new ServeAdapter(resolveServer(options.server, context.services), context.root, "website", {
3921
+ TEST_RUN_ID: context.started?.runId ?? randomUUID(),
3922
+ ...serverBackendEnv(options.backend, context.backendUrl)
3923
+ });
3924
+ try {
3925
+ return {
3926
+ baseUrl: await serve.start(),
3927
+ serve
3928
+ };
3929
+ } catch (error) {
3930
+ await context.started?.stopProcesses();
3931
+ await context.started?.orchestrator.stop();
3932
+ await context.backend?.stop();
3933
+ throw error;
3934
+ }
3935
+ }
3936
+ /** The stub's URL in the child's environment, under the name the caller chose. */
3937
+ function serverBackendEnv(backend, backendUrl) {
3938
+ return backend && backendUrl !== void 0 ? { [backend.env]: backendUrl } : {};
3939
+ }
3940
+ /** The declared record, or the empty one — a website may have no services. */
3941
+ function servicesOf(options) {
3942
+ return options.services ?? {};
3943
+ }
3274
3944
  async function startWebsite(options) {
3275
3945
  const callerDir = getCallerDir();
3276
3946
  await registerMatchers();
@@ -3281,18 +3951,16 @@ async function startWebsite(options) {
3281
3951
  backend = new StubBackend({ port: options.backend.port });
3282
3952
  backendUrl = await backend.start();
3283
3953
  }
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, "");
3954
+ const services = servicesOf(options);
3955
+ const root = resolveRoot(options.root, callerDir);
3956
+ const started = await startDeclared(services, root, backend);
3957
+ const { baseUrl, serve } = await startSite(options, {
3958
+ backend,
3959
+ backendUrl,
3960
+ root,
3961
+ services,
3962
+ started
3963
+ });
3296
3964
  let browser = null;
3297
3965
  const getBrowser = async () => {
3298
3966
  if (!browser) {
@@ -3301,13 +3969,6 @@ async function startWebsite(options) {
3301
3969
  }
3302
3970
  return browser;
3303
3971
  };
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
3972
  return {
3312
3973
  cleanup: async () => {
3313
3974
  if (browser) {
@@ -3315,10 +3976,21 @@ async function startWebsite(options) {
3315
3976
  browser = null;
3316
3977
  }
3317
3978
  if (serve) await serve.stop();
3979
+ if (started) {
3980
+ await started.stopProcesses();
3981
+ await releaseIsolation(services);
3982
+ await started.orchestrator.stop();
3983
+ }
3318
3984
  if (backend) await backend.stop();
3319
3985
  },
3320
3986
  url: baseUrl,
3321
- website: createWebsiteFacet(config)
3987
+ website: createWebsiteFacet({
3988
+ backend: backend ?? void 0,
3989
+ backendUrl,
3990
+ baseUrl,
3991
+ browser: getBrowser,
3992
+ external: options.external ?? (options.server ? "block" : "allow")
3993
+ })
3322
3994
  };
3323
3995
  }
3324
3996
  //#endregion
@@ -3339,6 +4011,13 @@ const specification = {
3339
4011
  */
3340
4012
  cli: startCli,
3341
4013
  /**
4014
+ * Specify a module against the real thing: a database, a cache, a
4015
+ * `process()` it talks to — or nothing at all, when its oracle is a
4016
+ * golden. `.call((services) => …)` is the terminal action, and what it
4017
+ * produced is read as `result.value` or, when it refused, `result.error`.
4018
+ */
4019
+ integration: startIntegration,
4020
+ /**
3342
4021
  * Test background jobs. Jobs run in-process by definition — no HTTP
3343
4022
  * server, no mode. `.trigger(name)` is the terminal action.
3344
4023
  */
@@ -3734,6 +4413,14 @@ function sqlite(options = {}) {
3734
4413
  }
3735
4414
  //#endregion
3736
4415
  //#region src/index.ts
4416
+ /**
4417
+ * The module-scope network double, on msw's node server — what a test with no
4418
+ * chain to hang contracts on reaches for (chapter 10).
4419
+ */
4420
+ const intercept = interceptThrough(async (contracts) => {
4421
+ const { registerContracts } = await import("./intercept.js");
4422
+ return await registerContracts(contracts);
4423
+ });
3737
4424
  registerContainerIntegrations({
3738
4425
  createComposeStack: (composeFile, projectName) => new ComposeStackAdapter(composeFile, projectName),
3739
4426
  createContainer: (options) => new TestcontainersAdapter(options),
@@ -3745,4 +4432,4 @@ registerComposeServiceFactory("postgres", (service) => postgres({
3745
4432
  }));
3746
4433
  registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
3747
4434
  //#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 };
4435
+ export { BaseResult, CallResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ProcessHandle, ResponseAccessor, ScreenResult, TableAccessor, TextAccessor, anthropic, banner, button, clock, complementary, component, content, contentinfo, defineContract, defineContracts, dialog, disabled, enabled, field, findContainersByLabel, focused, form, heading, http, inspectContainer, intercept, link, listitem, main, match, mockOf, mockOfDate, navigation, openai, option, postgres, processService as process, redis, region, removeContainers, required, row, search, selected, specification, sqlite, status, table, testId, text, valued, waitUntil, within };