@jterrazz/test 15.1.1 → 15.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -29
- package/dist/ambiguity.js +23 -4
- package/dist/artifacts.js +108 -163
- package/dist/browser/index.js +2796 -0
- package/dist/checker.js +470 -29
- package/dist/index.d.ts +1434 -609
- package/dist/index.js +3696 -2745
- package/dist/intercept.js +117 -59
- package/dist/oxlint.cjs +481 -35
- package/dist/oxlint.d.cts +13 -1
- package/dist/oxlint.d.ts +13 -1
- package/dist/oxlint.js +481 -35
- package/dist/playwright.adapter.js +96 -22
- package/dist/{queue.js → stream.js} +61 -2
- package/dist/vitest.d.ts +130 -2
- package/dist/vitest.js +433 -3
- package/package.json +37 -5
package/README.md
CHANGED
|
@@ -147,19 +147,22 @@ test('shows the events feed behind its deep link', async () => {
|
|
|
147
147
|
});
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
Actions are **terminal**: `.request()`, `.get()`, `.trigger()`, `.exec()`, `.fetch()`, `.visit()`, `.open()` execute the spec and resolve to a precisely typed result. There is no `.run()`, no label, and no `.spawn()`.
|
|
150
|
+
Actions are **terminal**: `.request()`, `.get()`, `.trigger()`, `.exec()`, `.call()`, `.fetch()`, `.visit()`, `.open()` execute the spec and resolve to a precisely typed result. There is no `.run()`, no label, and no `.spawn()`.
|
|
151
151
|
|
|
152
|
-
## The
|
|
152
|
+
## The six constructors
|
|
153
153
|
|
|
154
154
|
One constructor per tested interface, each returning a record destructured with its canonical name:
|
|
155
155
|
|
|
156
|
-
| Constructor
|
|
157
|
-
|
|
|
158
|
-
| `specification.api(options)`
|
|
159
|
-
| `specification.jobs(options)`
|
|
160
|
-
| `specification.cli(bin, options)`
|
|
161
|
-
| `specification.
|
|
162
|
-
| `specification.
|
|
156
|
+
| Constructor | Returns | Terminal actions |
|
|
157
|
+
| ---------------------------------- | ---------------------------------------- | ------------------------------------------------------------ |
|
|
158
|
+
| `specification.api(options)` | `{ api, cleanup, docker, orchestrator }` | `.request(file)`, `.get()`, `.post()`, `.put()`, `.delete()` |
|
|
159
|
+
| `specification.jobs(options)` | `{ jobs, cleanup, orchestrator }` | `.trigger(name)` |
|
|
160
|
+
| `specification.cli(bin, options)` | `{ cli, cleanup, docker, orchestrator }` | `.exec(args, { waitFor?, timeout? }?)` |
|
|
161
|
+
| `specification.integration(opts?)` | `{ integration, cleanup }` | `.call((services) => …)` |
|
|
162
|
+
| `specification.website(options)` | `{ website, cleanup, url }` | `.fetch(path)`, `.visit(path, scenario?)` |
|
|
163
|
+
| `specification.mobile(options)` | `{ mobile, cleanup, udid }` | `.open(deepLink?, scenario?)` |
|
|
164
|
+
|
|
165
|
+
A rendered component has no constructor: it starts nothing, so it is reached through the `component` chain directly (docs/16).
|
|
163
166
|
|
|
164
167
|
### `specification.api({ services, server, mode?, root? })`
|
|
165
168
|
|
|
@@ -292,16 +295,17 @@ Every artefact a run produces lands under `.artifacts/<tool>/` — one `.gitigno
|
|
|
292
295
|
|
|
293
296
|
### Setup (chainable)
|
|
294
297
|
|
|
295
|
-
| Method | Facets
|
|
296
|
-
| --------------------------------------- |
|
|
297
|
-
| `.seed("file.sql", { database? })` | all
|
|
298
|
-
| `.fixture("file")` | cli
|
|
299
|
-
| `.fixture("$FIXTURES/name/")` | cli
|
|
300
|
-
| `.env({ KEY: "value" })` | cli
|
|
301
|
-
| `.headers({ "Accept-Language": "fr" })` | api, website
|
|
302
|
-
| `.intercept(contracts)` | all but cli
|
|
303
|
-
| `.intercept(contract)` / `([a, b])` | all but cli
|
|
304
|
-
| `.intercept(request, response)` | all but cli
|
|
298
|
+
| Method | Facets | Description |
|
|
299
|
+
| --------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
300
|
+
| `.seed("file.sql", { database? })` | all | Load SQL from `_seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
|
|
301
|
+
| `.fixture("file")` | cli | Copy the feature-local `_fixtures/file` into the working directory |
|
|
302
|
+
| `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/_fixtures/<name>/` project into the cwd (trailing `/` = contents; layers) |
|
|
303
|
+
| `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
|
|
304
|
+
| `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
|
|
305
|
+
| `.intercept(contracts)` | all but cli | Declare the world: a `defineContracts(...)` composite — MSW on api/jobs, the stub backend on website/mobile |
|
|
306
|
+
| `.intercept(contract)` / `([a, b])` | all but cli | A single contract, or an ordered list |
|
|
307
|
+
| `.intercept(request, response)` | all but cli | Inline pair, for one-off plumbing |
|
|
308
|
+
| `.clock("2026-03-04T09:30:00Z")` | api, jobs, integration, website | Pin the calendar for this chain — the page's on website, this process's elsewhere |
|
|
305
309
|
|
|
306
310
|
### Actions (terminal)
|
|
307
311
|
|
|
@@ -444,23 +448,28 @@ test('deploy spawns a labelled container', async () => {
|
|
|
444
448
|
|
|
445
449
|
`sqlite()` caches its schema template inside the project — `.artifacts/vitest/sqlite/template-<key>.sqlite` — so two checkouts never share one, and workers racing for a cold cache wait for the one that is building rather than all building at once. Details: [docs/11-services.md](docs/11-services.md#where-the-template-lives).
|
|
446
450
|
|
|
447
|
-
##
|
|
451
|
+
## Also exported
|
|
448
452
|
|
|
449
|
-
|
|
450
|
-
import { mockOf, mockOfDate } from '@jterrazz/test';
|
|
451
|
-
```
|
|
453
|
+
Everything below comes from the package root (rule F1) — except the project helpers, which are `@jterrazz/test/vitest`'s. One line each; the chapter owns the detail.
|
|
452
454
|
|
|
453
|
-
| Export
|
|
454
|
-
|
|
|
455
|
-
| `
|
|
456
|
-
| `
|
|
455
|
+
| Export | Is | Chapter |
|
|
456
|
+
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
457
|
+
| `clock.at(iso)` · `.run(iso?)` · `.advance(ms)` | The one time primitive — a `using` scope that pins `Date`, replacing `vi.useFakeTimers()` and `mockOfDate` | [docs/12](docs/12-conventions.md#time--one-primitive-two-depths) |
|
|
458
|
+
| `intercept(…)` | The same network double with no chain: `await using _ = await intercept(…)` in a module test | [docs/10](docs/10-contracts.md#intercept--the-same-double-with-no-chain) |
|
|
459
|
+
| `process({ command, ready?, … })` | The one shape an external process takes — a dev server, an API, a bundler, owned by the runner | [docs/11](docs/11-services.md#process--the-one-shape-an-external-process-takes) |
|
|
460
|
+
| `http.stream(chunks, init?)` · `http.sse(events)` | A chunked or server-sent response body a contract replies with | [docs/10](docs/10-contracts.md) |
|
|
461
|
+
| `mockOf<T>()` · `mockOf<T>({ deep: false })` | Deep double of a port (a member never stubbed answers with another double), or the flat one | [docs/12](docs/12-conventions.md#the-doubles-ladder) |
|
|
462
|
+
| `mockOfDate` | Freeze/reset the global `Date` via `.set(date)` / `.reset()` — `clock` is what replaces it | [docs/12](docs/12-conventions.md#time--one-primitive-two-depths) |
|
|
463
|
+
| `required(value, why)` | The value, or a failure naming what was missing and why it mattered — instead of `!` | [docs/08](docs/08-assertions.md#two-helpers-that-are-not-matchers) |
|
|
464
|
+
| `waitUntil(predicate, { timeout?, interval?, why? })` | Wait on a CONDITION, never a duration, on a real-time budget a pinned clock cannot starve | [docs/08](docs/08-assertions.md#two-helpers-that-are-not-matchers) |
|
|
465
|
+
| `unit()` `component()` `api()` `jobs()` `cli()` `integration()` `website()` `mobile()` | The vitest project helpers, each taking `{ include, exclude, timeout, serial }` on top of the project it already is | [docs/02](docs/02-developing.md#vitest-config-the-preset) |
|
|
457
466
|
|
|
458
467
|
## Conventions
|
|
459
468
|
|
|
460
469
|
Normative rules live in the constitution ([docs/12-conventions.md](docs/12-conventions.md)); the generated per-rule catalogue is [docs/13-linting.md](docs/13-linting.md). A facet (`specs/<facet>/`) carries its runner(s) at its root and holds domain folders; the folder follows the assets:
|
|
461
470
|
|
|
462
471
|
```
|
|
463
|
-
specs/<facet>/ # api | jobs | cli |
|
|
472
|
+
specs/<facet>/ # api | jobs | cli | integration | website | mobile
|
|
464
473
|
├── <facet>.specification.ts # runner(s) at the facet ROOT (rule C1)
|
|
465
474
|
└── <domain>/ # a product command/area — 1..n test files
|
|
466
475
|
├── <aspect>.test.ts
|
|
@@ -485,7 +494,7 @@ These conventions are not just prose: the package ships an oxlint plugin (`@jter
|
|
|
485
494
|
- **vitest** - peer dependency
|
|
486
495
|
- **playwright** - optional peer dependency, only needed for `.visit()`: `npm install -D playwright && npx playwright install chromium`
|
|
487
496
|
- **appium + webdriverio** - optional peer dependencies, only needed for `specification.mobile()`: `npm install -D appium webdriverio && npx appium driver install xcuitest` — plus Xcode, a simulator, and the app installed on it
|
|
488
|
-
- **msw** - bundled as a direct dependency (powers `.intercept()`); no separate install
|
|
497
|
+
- **msw** - bundled as a direct dependency (powers `.intercept()` on a chain and `intercept()` in module scope, under node and in a page); no separate install
|
|
489
498
|
- **hono** (or any web framework) - supplied by your project for in-process apps; the adapter only needs an object with a `request()` method, so it is not a peer
|
|
490
499
|
|
|
491
500
|
## Docs
|
package/dist/ambiguity.js
CHANGED
|
@@ -19,14 +19,20 @@ const CONSTRUCTORS = {
|
|
|
19
19
|
button: "button",
|
|
20
20
|
complementary: "complementary",
|
|
21
21
|
contentinfo: "contentinfo",
|
|
22
|
+
dialog: "dialog",
|
|
22
23
|
field: "field",
|
|
23
24
|
form: "form",
|
|
24
25
|
heading: "heading",
|
|
25
26
|
link: "link",
|
|
27
|
+
listitem: "listitem",
|
|
26
28
|
main: "main",
|
|
27
29
|
navigation: "navigation",
|
|
30
|
+
option: "option",
|
|
28
31
|
region: "region",
|
|
32
|
+
row: "row",
|
|
29
33
|
search: "search",
|
|
34
|
+
status: "status",
|
|
35
|
+
table: "table",
|
|
30
36
|
testId: "testId",
|
|
31
37
|
text: "content"
|
|
32
38
|
};
|
|
@@ -62,12 +68,22 @@ function formatBare(element) {
|
|
|
62
68
|
/** `1. <a href="/articles">Articles</a> in <nav>` — one evidence line per candidate. */
|
|
63
69
|
function formatMatch(match, index) {
|
|
64
70
|
const attribute = match.detail ? ` ${quoteDetail(match)}` : "";
|
|
71
|
+
const named = match.accessibleName ? ` named ${JSON.stringify(match.accessibleName)}` : "";
|
|
65
72
|
const context = match.context ? ` in <${match.context}>` : "";
|
|
66
|
-
return ` ${index + 1}. <${match.tag}${attribute}>${match.text}</${match.tag}>${context}`;
|
|
73
|
+
return ` ${index + 1}. <${match.tag}${attribute}>${match.text}</${match.tag}>${named}${context}`;
|
|
67
74
|
}
|
|
68
75
|
function quoteDetail(match) {
|
|
69
76
|
return `${match.tag === "a" ? "href" : "name"}=${JSON.stringify(match.detail)}`;
|
|
70
77
|
}
|
|
78
|
+
/** Each landmark the candidates sit in, and how many of them it holds. */
|
|
79
|
+
function landmarkCounts(matches) {
|
|
80
|
+
const counts = /* @__PURE__ */ new Map();
|
|
81
|
+
for (const match of matches) {
|
|
82
|
+
const landmark = match.context === void 0 ? void 0 : CONTEXT_LANDMARKS[match.context];
|
|
83
|
+
if (landmark !== void 0) counts.set(landmark, (counts.get(landmark) ?? 0) + 1);
|
|
84
|
+
}
|
|
85
|
+
return [...counts].toSorted(([, a], [, b]) => a - b);
|
|
86
|
+
}
|
|
71
87
|
/**
|
|
72
88
|
* The rewrites worth offering, in the order they should be tried. Scoping is
|
|
73
89
|
* always available; `exact` only when it would actually narrow the set, and
|
|
@@ -76,9 +92,12 @@ function quoteDetail(match) {
|
|
|
76
92
|
*/
|
|
77
93
|
function formatFixes(element, matches) {
|
|
78
94
|
const fixes = [];
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
81
|
-
|
|
95
|
+
const narrowing = landmarkCounts(matches).filter(([, count]) => count < matches.length);
|
|
96
|
+
if (!element.scope) {
|
|
97
|
+
const [first, ...rest] = narrowing;
|
|
98
|
+
const alsoHere = rest.length > 0 ? ` [also here: ${rest.map(([name]) => name).join(", ")}]` : "";
|
|
99
|
+
fixes.push(first === void 0 ? `scope it within(<a container holding only this one>, ${formatBare(element)})` : `scope it within(${first[0]}, ${formatBare(element)}) [leaves ${first[1]} of ${matches.length}]${alsoHere}`);
|
|
100
|
+
}
|
|
82
101
|
if (!element.exact && element.name !== void 0) {
|
|
83
102
|
const remaining = matches.filter((match) => match.text === element.name).length;
|
|
84
103
|
if (remaining > 0 && remaining < matches.length) fixes.push(`exact name ${formatBare({
|
package/dist/artifacts.js
CHANGED
|
@@ -1,82 +1,10 @@
|
|
|
1
1
|
import { i as SpecSyntaxError, l as updateSpecFile, s as readSpecFile } from "./spec-document.js";
|
|
2
2
|
import { a as copyPlan, n as Matcher, r as TOKEN_KINDS, t as CaptureScope } from "./match.js";
|
|
3
3
|
import { cpSync, existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { dirname, isAbsolute,
|
|
5
|
-
import { execSync, spawn } from "node:child_process";
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { readdir } from "node:fs/promises";
|
|
7
7
|
import { createServer } from "node:net";
|
|
8
|
-
//#region src/specification/facets/_common/caller.ts
|
|
9
|
-
/** Sibling module tests are callers, not internals (CONVENTIONS I2). */
|
|
10
|
-
const TEST_FILE = /\.test\.[cm]?[jt]s$/u;
|
|
11
|
-
/** Where this very module sits inside the framework's SOURCE tree. */
|
|
12
|
-
const SOURCE_LOCATION = join("specification", "facets", "_common");
|
|
13
|
-
/** The real path of `path`, or `path` itself when it cannot be resolved. */
|
|
14
|
-
function realPath(path) {
|
|
15
|
-
try {
|
|
16
|
-
return realpathSync(path);
|
|
17
|
-
} catch {
|
|
18
|
-
return path;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Directory of the running framework module itself. From the built package
|
|
23
|
-
* this is `<package root>/dist`, whatever the resolution path — including a
|
|
24
|
-
* `file:` link, where the real path carries neither `node_modules` nor
|
|
25
|
-
* `/src/…` and would otherwise be mistaken for a caller frame.
|
|
26
|
-
*/
|
|
27
|
-
const FRAMEWORK_DIR = realPath(import.meta.dirname);
|
|
28
|
-
/**
|
|
29
|
-
* The directory holding the framework's OWN modules: `<package>/dist` for a
|
|
30
|
-
* built install (everything is bundled there), `<repo>/src` when the framework
|
|
31
|
-
* runs from source — this module's known location inside that tree is what
|
|
32
|
-
* tells the two apart.
|
|
33
|
-
*/
|
|
34
|
-
const FRAMEWORK_TREE = FRAMEWORK_DIR.endsWith(`${sep}${SOURCE_LOCATION}`) ? resolve(FRAMEWORK_DIR, "../../..") : FRAMEWORK_DIR;
|
|
35
|
-
/** Is `path` inside `directory` (strictly — a directory is not inside itself)? */
|
|
36
|
-
function isInside(directory, path) {
|
|
37
|
-
const rest = relative(directory, path);
|
|
38
|
-
return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Is this stack frame a module of the framework itself, rather than of the code
|
|
42
|
-
* calling it?
|
|
43
|
-
*
|
|
44
|
-
* Answered by IDENTITY: the frame is inside the framework's own directory.
|
|
45
|
-
* Answering it by substring — `/src/core/`, `/src/integrations/`, `/src/vitest/`
|
|
46
|
-
* — was answering a different question, "does this path look like the framework's
|
|
47
|
-
* layout", and a consumer that happens to have its own `src/core/` matched it.
|
|
48
|
-
* Its files were then skipped as framework internals, and fixture resolution
|
|
49
|
-
* anchored on the wrong directory (or fell through to the cwd) with nothing
|
|
50
|
-
* reported.
|
|
51
|
-
*
|
|
52
|
-
* @internal
|
|
53
|
-
*/
|
|
54
|
-
function isFrameworkFrame(filePath, frameworkTree = FRAMEWORK_TREE) {
|
|
55
|
-
if (TEST_FILE.test(filePath)) return false;
|
|
56
|
-
return isInside(realPath(frameworkTree), realPath(filePath));
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Detect the directory of the first stack frame that lives outside this
|
|
60
|
-
* package. Used to anchor fixture resolution (`_seeds/`, `_requests/`,
|
|
61
|
-
* `_expected/`, …) on the calling test / specification file.
|
|
62
|
-
*
|
|
63
|
-
* @internal
|
|
64
|
-
*/
|
|
65
|
-
function getCallerDir() {
|
|
66
|
-
const { stack } = /* @__PURE__ */ new Error("caller detection");
|
|
67
|
-
if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
|
|
68
|
-
const lines = stack.split("\n");
|
|
69
|
-
for (const line of lines) {
|
|
70
|
-
const match = /at\s+(?:.*?\()?(?:file:\/\/)?(?<filePath>[^:)]+):\d+:\d+/u.exec(line);
|
|
71
|
-
if (!match?.groups?.filePath) continue;
|
|
72
|
-
const { filePath } = match.groups;
|
|
73
|
-
if (filePath.includes("node_modules")) continue;
|
|
74
|
-
if (isFrameworkFrame(filePath)) continue;
|
|
75
|
-
return resolve(filePath, "..");
|
|
76
|
-
}
|
|
77
|
-
return process.cwd();
|
|
78
|
-
}
|
|
79
|
-
//#endregion
|
|
80
8
|
//#region src/specification/facets/_common/result/grep.ts
|
|
81
9
|
/**
|
|
82
10
|
* Extract text blocks from output that contain a pattern.
|
|
@@ -163,26 +91,6 @@ var TextAccessor = class TextAccessor {
|
|
|
163
91
|
return this.text;
|
|
164
92
|
}
|
|
165
93
|
};
|
|
166
|
-
/**
|
|
167
|
-
* Wrap an arbitrary string into a {@link TextAccessor} anchored on the calling
|
|
168
|
-
* test's directory — the same caller-detection the builders use.
|
|
169
|
-
*
|
|
170
|
-
* The product surface of a test framework is its own error messages, checker
|
|
171
|
-
* output, and reports; those deserve the same goldening as any other output.
|
|
172
|
-
* `text()` makes an ad-hoc string a first-class snapshot subject:
|
|
173
|
-
*
|
|
174
|
-
* ```typescript
|
|
175
|
-
* const message = await catchMessage(() => expect(result.response).toMatch('wrong-body.http'));
|
|
176
|
-
* expect(text(message)).toMatch('wrong-body-error.txt'); // resolves to _expected/
|
|
177
|
-
* ```
|
|
178
|
-
*
|
|
179
|
-
* ANSI is stripped before every comparison (the raw form stays on `.text`),
|
|
180
|
-
* the `{{token}}` grammar applies to the fixture, and `.grep()` composition
|
|
181
|
-
* works exactly as on any stream accessor.
|
|
182
|
-
*/
|
|
183
|
-
function text(value) {
|
|
184
|
-
return new TextAccessor(value, "text", getCallerDir());
|
|
185
|
-
}
|
|
186
94
|
//#endregion
|
|
187
95
|
//#region src/specification/facets/_common/result/json.ts
|
|
188
96
|
/**
|
|
@@ -215,7 +123,13 @@ var JsonAccessor = class {
|
|
|
215
123
|
this.transform = transform;
|
|
216
124
|
this.captures = captures ?? new CaptureScope();
|
|
217
125
|
}
|
|
218
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* The parsed JSON value. Throws if the text is not valid JSON.
|
|
128
|
+
*
|
|
129
|
+
* `Value` is `unknown` unless the subject that produced this accessor
|
|
130
|
+
* knows better — `.call<T>()` does, and hands its T straight through so a
|
|
131
|
+
* one-field reading needs no cast in the test.
|
|
132
|
+
*/
|
|
219
133
|
get value() {
|
|
220
134
|
const stripped = stripAnsiCodes(this.rawText);
|
|
221
135
|
const source = this.transform ? this.transform(stripped) : stripped;
|
|
@@ -228,45 +142,35 @@ var JsonAccessor = class {
|
|
|
228
142
|
}
|
|
229
143
|
};
|
|
230
144
|
//#endregion
|
|
231
|
-
//#region src/
|
|
145
|
+
//#region src/specification/facets/_common/result/table.ts
|
|
232
146
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
147
|
+
* Read-only accessor for a database table after a specification run.
|
|
148
|
+
*
|
|
149
|
+
* Assertions go through `expect()` (async — they query the database):
|
|
150
|
+
*
|
|
151
|
+
* await expect(result.table('users', { database: 'db' })).toMatchRows({
|
|
152
|
+
* columns: ['name'],
|
|
153
|
+
* rows: [['Alice']],
|
|
154
|
+
* });
|
|
155
|
+
* await expect(result.table('users', { database: 'db' })).toBeEmpty();
|
|
237
156
|
*/
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
157
|
+
var TableAccessor = class {
|
|
158
|
+
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
159
|
+
captures;
|
|
160
|
+
/** @internal */
|
|
161
|
+
db;
|
|
162
|
+
/** The table name this accessor reads. */
|
|
163
|
+
name;
|
|
164
|
+
constructor(name, db, captures) {
|
|
165
|
+
this.name = name;
|
|
166
|
+
this.db = db;
|
|
167
|
+
this.captures = captures ?? new CaptureScope();
|
|
248
168
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
timeout: DEFAULT_TIMEOUT
|
|
255
|
-
});
|
|
256
|
-
const parsed = JSON.parse(raw);
|
|
257
|
-
return Array.isArray(parsed) ? parsed[0] : parsed;
|
|
258
|
-
}
|
|
259
|
-
/** Force-remove the given container IDs in a single call. Errors are swallowed. */
|
|
260
|
-
function removeContainers(ids) {
|
|
261
|
-
if (ids.length === 0) return;
|
|
262
|
-
try {
|
|
263
|
-
execSync(`docker rm -f ${ids.join(" ")}`, {
|
|
264
|
-
encoding: "utf8",
|
|
265
|
-
stdio: "pipe",
|
|
266
|
-
timeout: DEFAULT_TIMEOUT
|
|
267
|
-
});
|
|
268
|
-
} catch {}
|
|
269
|
-
}
|
|
169
|
+
/** @internal Query the table — used by the toMatchRows / toBeEmpty matchers. */
|
|
170
|
+
async query(columns) {
|
|
171
|
+
return await this.db.query(this.name, columns);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
270
174
|
//#endregion
|
|
271
175
|
//#region src/specification/matching/structural.ts
|
|
272
176
|
/**
|
|
@@ -605,6 +509,46 @@ function mergeTextPreservingPlaceholders(previous, actual, scope) {
|
|
|
605
509
|
return merged.join("\n");
|
|
606
510
|
}
|
|
607
511
|
//#endregion
|
|
512
|
+
//#region src/integrations/docker/docker-lookup.ts
|
|
513
|
+
/**
|
|
514
|
+
* Thin synchronous wrappers around the `docker` CLI used by the docker() spec
|
|
515
|
+
* mode. Sync is deliberate — the calls are fast, run only on the host, and
|
|
516
|
+
* keep the dispose path straightforward (`Symbol.asyncDispose` can still be
|
|
517
|
+
* async without needing these to be).
|
|
518
|
+
*/
|
|
519
|
+
const DEFAULT_TIMEOUT = 1e4;
|
|
520
|
+
/** Return all container IDs (running or stopped) that carry `key=value`. */
|
|
521
|
+
function findContainersByLabel(key, value) {
|
|
522
|
+
try {
|
|
523
|
+
return execSync(`docker ps -aq -f label=${key}=${value}`, {
|
|
524
|
+
encoding: "utf8",
|
|
525
|
+
timeout: DEFAULT_TIMEOUT
|
|
526
|
+
}).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
527
|
+
} catch {
|
|
528
|
+
return [];
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/** Return the raw `docker inspect` payload (object, not array) for a container. */
|
|
532
|
+
function inspectContainer(id) {
|
|
533
|
+
const raw = execSync(`docker inspect ${id}`, {
|
|
534
|
+
encoding: "utf8",
|
|
535
|
+
timeout: DEFAULT_TIMEOUT
|
|
536
|
+
});
|
|
537
|
+
const parsed = JSON.parse(raw);
|
|
538
|
+
return Array.isArray(parsed) ? parsed[0] : parsed;
|
|
539
|
+
}
|
|
540
|
+
/** Force-remove the given container IDs in a single call. Errors are swallowed. */
|
|
541
|
+
function removeContainers(ids) {
|
|
542
|
+
if (ids.length === 0) return;
|
|
543
|
+
try {
|
|
544
|
+
execSync(`docker rm -f ${ids.join(" ")}`, {
|
|
545
|
+
encoding: "utf8",
|
|
546
|
+
stdio: "pipe",
|
|
547
|
+
timeout: DEFAULT_TIMEOUT
|
|
548
|
+
});
|
|
549
|
+
} catch {}
|
|
550
|
+
}
|
|
551
|
+
//#endregion
|
|
608
552
|
//#region src/specification/facets/_common/result/directory.ts
|
|
609
553
|
const DEFAULT_IGNORES = [
|
|
610
554
|
".git",
|
|
@@ -811,36 +755,6 @@ function expandWorkdir(env, workDir) {
|
|
|
811
755
|
return expanded;
|
|
812
756
|
}
|
|
813
757
|
//#endregion
|
|
814
|
-
//#region src/specification/facets/_common/result/table.ts
|
|
815
|
-
/**
|
|
816
|
-
* Read-only accessor for a database table after a specification run.
|
|
817
|
-
*
|
|
818
|
-
* Assertions go through `expect()` (async — they query the database):
|
|
819
|
-
*
|
|
820
|
-
* await expect(result.table('users', { database: 'db' })).toMatchRows({
|
|
821
|
-
* columns: ['name'],
|
|
822
|
-
* rows: [['Alice']],
|
|
823
|
-
* });
|
|
824
|
-
* await expect(result.table('users', { database: 'db' })).toBeEmpty();
|
|
825
|
-
*/
|
|
826
|
-
var TableAccessor = class {
|
|
827
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
828
|
-
captures;
|
|
829
|
-
/** @internal */
|
|
830
|
-
db;
|
|
831
|
-
/** The table name this accessor reads. */
|
|
832
|
-
name;
|
|
833
|
-
constructor(name, db, captures) {
|
|
834
|
-
this.name = name;
|
|
835
|
-
this.db = db;
|
|
836
|
-
this.captures = captures ?? new CaptureScope();
|
|
837
|
-
}
|
|
838
|
-
/** @internal Query the table — used by the toMatchRows / toBeEmpty matchers. */
|
|
839
|
-
async query(columns) {
|
|
840
|
-
return await this.db.query(this.name, columns);
|
|
841
|
-
}
|
|
842
|
-
};
|
|
843
|
-
//#endregion
|
|
844
758
|
//#region src/specification/facets/_common/result/result.ts
|
|
845
759
|
/**
|
|
846
760
|
* Base result - common accessors available after any action type.
|
|
@@ -1351,6 +1265,16 @@ function shouldUpdateSnapshots() {
|
|
|
1351
1265
|
}
|
|
1352
1266
|
/** Standard hint appended to missing-fixture errors. */
|
|
1353
1267
|
const UPDATE_HINT = "Run with TEST_UPDATE=1 (or vitest -u) to create it.";
|
|
1268
|
+
/**
|
|
1269
|
+
* The hint a FROZEN golden gets instead. `{ frozen }` is the switch that tells
|
|
1270
|
+
* update mode to leave this file alone, so sending its author to `TEST_UPDATE=1`
|
|
1271
|
+
* would send them to a run that does nothing.
|
|
1272
|
+
*/
|
|
1273
|
+
const FROZEN_HINT = "This golden is `{ frozen }`: update mode never writes it. Write the file by hand, or drop `{ frozen }` for one TEST_UPDATE=1 run and put it back.";
|
|
1274
|
+
/** The hint a missing golden earns, given whether it is frozen. */
|
|
1275
|
+
function missingHint(frozen) {
|
|
1276
|
+
return frozen ? FROZEN_HINT : UPDATE_HINT;
|
|
1277
|
+
}
|
|
1354
1278
|
//#endregion
|
|
1355
1279
|
//#region src/specification/facets/website/serve.adapter.ts
|
|
1356
1280
|
/** Grace period between SIGTERM and the SIGKILL escalation. */
|
|
@@ -1408,14 +1332,16 @@ var ServeAdapter = class {
|
|
|
1408
1332
|
}
|
|
1409
1333
|
/** Start the server and resolve with its base URL once it is ready. */
|
|
1410
1334
|
async start() {
|
|
1335
|
+
this.runBefore();
|
|
1411
1336
|
const banner = this.options.ready instanceof RegExp ? this.options.ready : null;
|
|
1412
1337
|
const port = banner ? null : this.options.port ?? await findFreePort();
|
|
1413
1338
|
const timeout = this.options.timeout ?? DEFAULT_READY_TIMEOUT;
|
|
1414
1339
|
this.child = spawn(this.options.command, [], {
|
|
1415
|
-
cwd: this.
|
|
1340
|
+
cwd: this.workingDirectory(),
|
|
1416
1341
|
detached: process.platform !== "win32",
|
|
1417
1342
|
env: {
|
|
1418
1343
|
...process.env,
|
|
1344
|
+
...typeof this.options.env === "function" ? {} : this.options.env,
|
|
1419
1345
|
...this.extraEnv,
|
|
1420
1346
|
...port === null ? {} : { PORT: String(port) }
|
|
1421
1347
|
},
|
|
@@ -1476,6 +1402,25 @@ var ServeAdapter = class {
|
|
|
1476
1402
|
}
|
|
1477
1403
|
}
|
|
1478
1404
|
}
|
|
1405
|
+
/** Where the child runs: the stated `cwd` under the root, or the root. */
|
|
1406
|
+
workingDirectory() {
|
|
1407
|
+
return this.options.cwd === void 0 ? this.root : resolve(this.root, this.options.cwd);
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* The one-shot command, run before the process is spawned. It must exit 0:
|
|
1411
|
+
* a build that failed is not a spec that failed later on a missing file,
|
|
1412
|
+
* and the difference is what this reports.
|
|
1413
|
+
*/
|
|
1414
|
+
runBefore() {
|
|
1415
|
+
const { before } = this.options;
|
|
1416
|
+
if (before === void 0) return;
|
|
1417
|
+
const outcome = spawnSync(before, [], {
|
|
1418
|
+
cwd: this.workingDirectory(),
|
|
1419
|
+
encoding: "utf8",
|
|
1420
|
+
shell: true
|
|
1421
|
+
});
|
|
1422
|
+
if (outcome.status !== 0) throw new Error(`specification.${this.facet}(): \`before\` exited ${outcome.status ?? "without a status"}.\nCommand: ${before}\nOutput:\n${(outcome.stdout ?? "") + (outcome.stderr ?? "")}`);
|
|
1423
|
+
}
|
|
1479
1424
|
/** Terminate the server process group (idempotent). */
|
|
1480
1425
|
async stop() {
|
|
1481
1426
|
const { child } = this;
|
|
@@ -1794,4 +1739,4 @@ const COVERAGE_DIR = `${VITEST_ARTIFACTS_DIR}/coverage`;
|
|
|
1794
1739
|
/** Where `sqlite()` caches the schema template it copies per worker. */
|
|
1795
1740
|
const SQLITE_TEMPLATE_DIR = `${VITEST_ARTIFACTS_DIR}/sqlite`;
|
|
1796
1741
|
//#endregion
|
|
1797
|
-
export {
|
|
1742
|
+
export { mergePreservingPlaceholders as A, DirectoryAccessor as C, inspectContainer as D, findContainersByLabel as E, structuralSubset as F, textEquals as I, TableAccessor as L, placeholderPatternSource as M, renderExpected as N, removeContainers as O, structuralEquals as P, JsonAccessor as R, FilesystemAccessor as S, walkDirectory as T, validateDatabaseOption as _, specDescription as a, resolveCommand as b, shouldUpdateSnapshots as c, formatStartupReport as d, formatStdoutDiff as f, BaseResult as g, CliResult as h, runSpecDocument as i, mergeTextPreservingPlaceholders as j, hasPlaceholders as k, formatDirectoryDiff as l, ContainerAccessor as m, SQLITE_TEMPLATE_DIR as n, ServeAdapter as o, formatTableDiff as p, VITEST_ARTIFACTS_DIR as r, missingHint as s, COVERAGE_DIR as t, formatResponseDiff as u, discoverRoot as v, diffDirectories as w, resolveRoot as x, expandWorkdir as y, TextAccessor as z };
|