@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/README.md +120 -418
- package/dist/ambiguity.js +7 -6
- package/dist/appium.adapter.js +12 -11
- package/dist/artifacts.js +25 -1698
- package/dist/browser/index.js +1484 -1142
- package/dist/checker.d.ts +1 -1
- package/dist/checker.js +1308 -76
- package/dist/fixtures.js +110 -0
- package/dist/index.d.ts +1482 -1011
- package/dist/index.js +1715 -1173
- package/dist/literate.js +1747 -0
- package/dist/match.js +625 -84
- package/dist/oxlint.cjs +3534 -1469
- package/dist/oxlint.d.cts +60 -48
- package/dist/oxlint.d.ts +60 -48
- package/dist/oxlint.js +3534 -1469
- package/dist/playwright.adapter.js +101 -6
- package/dist/{intercept.js → server.js} +11 -3
- package/dist/{queue.js → stream.js} +63 -4
- package/dist/vitest.d.ts +85 -36
- package/dist/vitest.js +177 -43
- package/package.json +31 -11
- package/dist/spec-document.js +0 -651
package/dist/index.js
CHANGED
|
@@ -1,401 +1,335 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { a as
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
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/
|
|
27
|
+
//#region src/model/contracts/intercept.ts
|
|
67
28
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
cwd: dirname(this.composeFile),
|
|
86
|
-
encoding: "utf8",
|
|
87
|
-
timeout: 12e4
|
|
88
|
-
}).trim();
|
|
98
|
+
registration = await register(contracts);
|
|
89
99
|
} catch (error) {
|
|
90
|
-
|
|
91
|
-
throw
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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/
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
this.
|
|
127
|
-
this.
|
|
128
|
-
this.
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
153
|
-
if (!this.
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
166
|
-
if (this.
|
|
167
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
return
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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/
|
|
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 ? {
|
|
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 ? {
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
-
*
|
|
927
|
-
*
|
|
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
|
-
|
|
1402
|
+
function processService(options) {
|
|
1403
|
+
return new ProcessHandle(options);
|
|
1404
|
+
}
|
|
930
1405
|
//#endregion
|
|
931
|
-
//#region src/
|
|
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
|
-
*
|
|
934
|
-
*
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
*
|
|
941
|
-
*
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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/
|
|
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/
|
|
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
|
-
/**
|
|
1126
|
-
function
|
|
1127
|
-
const
|
|
1128
|
-
const
|
|
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
|
-
|
|
1138
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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/
|
|
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
|
|
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${
|
|
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${
|
|
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${
|
|
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${
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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("./
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
|
2857
|
+
database,
|
|
2710
2858
|
databaseKeys,
|
|
2711
|
-
databases
|
|
2712
|
-
|
|
2713
|
-
server: new FetchAdapter(appUrl)
|
|
2859
|
+
databases,
|
|
2860
|
+
server: new HonoAdapter(options.server(services))
|
|
2714
2861
|
}),
|
|
2715
2862
|
cleanup: async () => {
|
|
2716
|
-
await
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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:
|
|
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/
|
|
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/
|
|
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
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
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(
|
|
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/
|
|
3621
|
+
//#region src/facets/specification.ts
|
|
3326
3622
|
const specification = {
|
|
3327
3623
|
/**
|
|
3328
|
-
* Test an HTTP app.
|
|
3329
|
-
*
|
|
3330
|
-
*
|
|
3331
|
-
*
|
|
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/
|
|
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/
|
|
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/
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
4207
|
+
const templateDb = new (database())(buildPath);
|
|
3665
4208
|
templateDb.exec(sql);
|
|
3666
4209
|
templateDb.close();
|
|
3667
|
-
} else new
|
|
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
|
|
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
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
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
|
-
|
|
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 {
|
|
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 };
|