@jterrazz/test 18.0.0 → 18.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/shared-serve.js +80 -11
- package/dist/vitest.js +3 -3
- package/docs/reference/services.md +13 -9
- package/docs/start/set-up-a-project.md +10 -10
- package/package.json +1 -1
package/dist/shared-serve.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { n as refusal } from "./shared-refusal.js";
|
|
2
2
|
import { n as untilDeadline } from "./shared-pause.js";
|
|
3
|
-
import
|
|
3
|
+
import "./shared-listened-port.js";
|
|
4
4
|
import "node:module";
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
6
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
7
|
-
import {
|
|
7
|
+
import { randomInt } from "node:crypto";
|
|
8
|
+
import { Socket, connect } from "node:net";
|
|
8
9
|
import { once } from "node:events";
|
|
10
|
+
import { createSocket } from "node:dgram";
|
|
9
11
|
//#region \0rolldown/runtime.js
|
|
10
12
|
var __defProp = Object.defineProperty;
|
|
11
13
|
var __exportAll = (all, no_symbols) => {
|
|
@@ -19,13 +21,6 @@ var __exportAll = (all, no_symbols) => {
|
|
|
19
21
|
};
|
|
20
22
|
//#endregion
|
|
21
23
|
//#region src/services/process/listening.ts
|
|
22
|
-
async function findFreePort() {
|
|
23
|
-
const server = createServer();
|
|
24
|
-
const port = await listenedPort(server);
|
|
25
|
-
server.close();
|
|
26
|
-
await once(server, "close");
|
|
27
|
-
return port;
|
|
28
|
-
}
|
|
29
24
|
async function firstAnswering(urls) {
|
|
30
25
|
return (await Promise.all(urls.map(async (url) => await answers(url) ? url : void 0))).find((url) => url !== void 0);
|
|
31
26
|
}
|
|
@@ -98,6 +93,69 @@ async function refuseHeldPort(port, specification, command) {
|
|
|
98
93
|
}));
|
|
99
94
|
}
|
|
100
95
|
//#endregion
|
|
96
|
+
//#region src/services/process/port-reservation.ts
|
|
97
|
+
const DRAWN_PORTS = {
|
|
98
|
+
first: 1e4,
|
|
99
|
+
last: 32767
|
|
100
|
+
};
|
|
101
|
+
function draw() {
|
|
102
|
+
return randomInt(DRAWN_PORTS.first, DRAWN_PORTS.last + 1);
|
|
103
|
+
}
|
|
104
|
+
async function claimPort(port) {
|
|
105
|
+
const socket = createSocket("udp4");
|
|
106
|
+
const failure = await new Promise((resolve) => {
|
|
107
|
+
socket.on("error", resolve);
|
|
108
|
+
socket.bind({
|
|
109
|
+
address: "127.0.0.1",
|
|
110
|
+
port
|
|
111
|
+
}, () => {
|
|
112
|
+
resolve(void 0);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
if (failure === void 0) {
|
|
116
|
+
socket.unref();
|
|
117
|
+
return {
|
|
118
|
+
port,
|
|
119
|
+
release: closing(socket)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
socket.close();
|
|
123
|
+
return failure instanceof Error && "code" in failure && failure.code === "EADDRINUSE" ? void 0 : {
|
|
124
|
+
port,
|
|
125
|
+
release: () => void 0
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function closing(socket) {
|
|
129
|
+
let open = true;
|
|
130
|
+
return () => {
|
|
131
|
+
if (open) {
|
|
132
|
+
open = false;
|
|
133
|
+
socket.close();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function reserveFreePort(pick = draw) {
|
|
138
|
+
for (let attempt = 0; attempt < 64; attempt += 1) {
|
|
139
|
+
const reservation = await claimPort(pick());
|
|
140
|
+
if (reservation === void 0) continue;
|
|
141
|
+
if (await holdingHost(reservation.port) === void 0) return reservation;
|
|
142
|
+
reservation.release();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** The port a served process is handed (the one `port` pins, else a drawn one) reserved for it, or the lesson of why none could be. */
|
|
146
|
+
async function reservePort(pinned, pick = draw) {
|
|
147
|
+
if (pinned !== void 0) return await claimPort(pinned) ?? {
|
|
148
|
+
fix: "drop the `port` option so each process is handed a free one, or run the specifications that pin it one at a time",
|
|
149
|
+
found: `port ${String(pinned)}, which \`port\` pins, is reserved by another served process on this machine`,
|
|
150
|
+
why: "A port is one served process's at a time, and a server sharing it would answer in the other's place."
|
|
151
|
+
};
|
|
152
|
+
return await reserveFreePort(pick) ?? {
|
|
153
|
+
fix: "stop the servers holding the ports of that range, or pin a free one with `port`",
|
|
154
|
+
found: `no free port in ${String(DRAWN_PORTS.first)}-${String(DRAWN_PORTS.last)} after ${String(64)} draws`,
|
|
155
|
+
why: "A process is handed a port drawn below the range the OS hands unasked ports from, and one nothing holds."
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
101
159
|
//#region src/services/process/process-group.ts
|
|
102
160
|
const KILL_GRACE_MS = 2e3;
|
|
103
161
|
/** outside: Windows has no process groups to signal through a negative pid. */
|
|
@@ -241,7 +299,7 @@ function runToCompletion(command, env) {
|
|
|
241
299
|
stdio: "pipe"
|
|
242
300
|
});
|
|
243
301
|
}
|
|
244
|
-
/** Any HTTP status counts as ready. The child runs in its own process group so `stop()` kills a `next start` grandchild too, and a watchdog kills that group when the run dies before `stop()`; both reap the strays it declares, the processes it spawns outside its group. */
|
|
302
|
+
/** Any HTTP status counts as ready, since the port it is handed is reserved for it until `stop()`: no other served process is handed it, and no socket is handed it by chance. The child runs in its own process group so `stop()` kills a `next start` grandchild too, and a watchdog kills that group when the run dies before `stop()`; both reap the strays it declares, the processes it spawns outside its group. */
|
|
245
303
|
var ServedProcess = class {
|
|
246
304
|
port = null;
|
|
247
305
|
child = null;
|
|
@@ -249,6 +307,7 @@ var ServedProcess = class {
|
|
|
249
307
|
extraEnv;
|
|
250
308
|
specification;
|
|
251
309
|
options;
|
|
310
|
+
reservation = null;
|
|
252
311
|
exited = false;
|
|
253
312
|
output = "";
|
|
254
313
|
root;
|
|
@@ -265,7 +324,7 @@ var ServedProcess = class {
|
|
|
265
324
|
await refuseHeldPort(this.options.port, this.specification, this.options.command);
|
|
266
325
|
this.runBefore();
|
|
267
326
|
const banner = this.options.ready instanceof RegExp ? this.options.ready : null;
|
|
268
|
-
const port = banner ? null : this.
|
|
327
|
+
const port = banner ? null : await this.reserve();
|
|
269
328
|
this.spawnServer(port);
|
|
270
329
|
return (banner ? await this.awaitBanner(banner) : await this.awaitAnswer(port ?? 0)) ?? await this.refuse({
|
|
271
330
|
fix: "run the command by hand and correct what its output names",
|
|
@@ -273,6 +332,14 @@ var ServedProcess = class {
|
|
|
273
332
|
why: "A process is handed to the chain once it is ready, and one that exits never is."
|
|
274
333
|
});
|
|
275
334
|
}
|
|
335
|
+
async reserve() {
|
|
336
|
+
const reserved = await reservePort(this.options.port);
|
|
337
|
+
if ("release" in reserved) {
|
|
338
|
+
this.reservation = reserved;
|
|
339
|
+
return reserved.port;
|
|
340
|
+
}
|
|
341
|
+
return await this.refuse(reserved);
|
|
342
|
+
}
|
|
276
343
|
environment(port) {
|
|
277
344
|
return {
|
|
278
345
|
...process.env,
|
|
@@ -392,6 +459,8 @@ var ServedProcess = class {
|
|
|
392
459
|
}));
|
|
393
460
|
}
|
|
394
461
|
async stop() {
|
|
462
|
+
this.reservation?.release();
|
|
463
|
+
this.reservation = null;
|
|
395
464
|
const { child } = this;
|
|
396
465
|
if (!child) return;
|
|
397
466
|
this.child = null;
|
package/dist/vitest.js
CHANGED
|
@@ -452,8 +452,8 @@ function isInline(project) {
|
|
|
452
452
|
function rootExcludeBeside(projects, exclude) {
|
|
453
453
|
return projects.every(isInline) ? exclude.filter((glob) => !EXCLUDE.includes(glob)) : exclude;
|
|
454
454
|
}
|
|
455
|
-
function withProjectDefaults(project) {
|
|
456
|
-
return isInline(project) ? onProjectDefaults(project) : project;
|
|
455
|
+
function withProjectDefaults(project, maxWorkers) {
|
|
456
|
+
return isInline(project) ? mergeConfig({ test: { maxWorkers } }, onProjectDefaults(project)) : project;
|
|
457
457
|
}
|
|
458
458
|
function presetConfig(stated = {}) {
|
|
459
459
|
const merged = mergeConfig(rootDefaults(), stated);
|
|
@@ -463,7 +463,7 @@ function presetConfig(stated = {}) {
|
|
|
463
463
|
...merged,
|
|
464
464
|
test: root
|
|
465
465
|
};
|
|
466
|
-
const inline = projects.map(withProjectDefaults);
|
|
466
|
+
const inline = projects.map((project) => withProjectDefaults(project, root.maxWorkers));
|
|
467
467
|
return {
|
|
468
468
|
...merged,
|
|
469
469
|
test: {
|
|
@@ -49,15 +49,15 @@ A site's dev server, an API the site calls, the bundler a simulator loads from:
|
|
|
49
49
|
|
|
50
50
|
The options type is `ProcessOptions`, one shape for the one external process every specification starts.
|
|
51
51
|
|
|
52
|
-
| Option | Means
|
|
53
|
-
| --------- |
|
|
54
|
-
| `command` | the shell command that starts it, receiving the chosen port as `PORT`
|
|
55
|
-
| `ready` | a **path** polled until it answers (default `/`), or a **RegExp** over the child's output whose one capture group is the port
|
|
56
|
-
| `port` | a fixed port instead of a free one the
|
|
57
|
-
| `cwd` | a working directory relative to the project root, or a function returning one, called when the process starts (once per file, for a spec that lays a fresh project for each)
|
|
58
|
-
| `env` | the child's environment: an object, or a function of the services started beside it
|
|
59
|
-
| `before` | a one-shot command run ONCE per evaluation of the specification module (under vitest's default isolation, once per test file), which must exit 0: a build, a migration, a fixture load; it runs with the same `env` as the command, so a build that reads a variable reads it here too
|
|
60
|
-
| `timeout` | the readiness budget, in milliseconds (default 30 000)
|
|
52
|
+
| Option | Means |
|
|
53
|
+
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
54
|
+
| `command` | the shell command that starts it, receiving the chosen port as `PORT` |
|
|
55
|
+
| `ready` | a **path** polled until it answers (default `/`), or a **RegExp** over the child's output whose one capture group is the port |
|
|
56
|
+
| `port` | a fixed port instead of a free one the framework draws; a port another served process holds, or another process already answers on (`127.0.0.1` or `::1`, the hosts readiness asks) is refused before the command runs, since it would answer in the server's place, and a server that cannot bind it (`EADDRINUSE`) is refused naming this option, whatever it printed |
|
|
57
|
+
| `cwd` | a working directory relative to the project root, or a function returning one, called when the process starts (once per file, for a spec that lays a fresh project for each) |
|
|
58
|
+
| `env` | the child's environment: an object, or a function of the services started beside it |
|
|
59
|
+
| `before` | a one-shot command run ONCE per evaluation of the specification module (under vitest's default isolation, once per test file), which must exit 0: a build, a migration, a fixture load; it runs with the same `env` as the command, so a build that reads a variable reads it here too |
|
|
60
|
+
| `timeout` | the readiness budget, in milliseconds (default 30 000) |
|
|
61
61
|
|
|
62
62
|
It sits in a `services` record like any other service, so it starts after the databases and can be handed their connection strings, and it is stopped with the specification:
|
|
63
63
|
|
|
@@ -82,6 +82,10 @@ export const { cleanup, website } = await specification.website({
|
|
|
82
82
|
});
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
**A port is one process's at a time.** The port handed as `PORT` is drawn between 10000 and 32767, below the range every OS hands unasked ports from (a `listen(0)`, a stub's port, an outgoing connection), so no other socket is handed it by chance while the command starts. It is reserved from the draw until the process stops, against every served process of the machine whatever run started it, so files running in parallel never share one; a draw that finds no free port in 64 tries is refused. A `port` two processes pin at once is held by the first, and the second is refused before its command runs.
|
|
86
|
+
|
|
87
|
+
**Any status the `ready` path answers is the server's.** Nothing else can answer on a port reserved that way: the one process that could is one started by hand on that very number while the command starts.
|
|
88
|
+
|
|
85
89
|
**Its URL is known once it is ready.** `url` read at declaration time is refused: a process started beside others reads theirs from the started services record: `env: (services) => ...`, `server: (services) => ...`.
|
|
86
90
|
|
|
87
91
|
**The framework ends what it started.** A process is spawned detached (its own process group on POSIX), and on teardown the whole group is sent `SIGTERM`, escalating to `SIGKILL` after a 2 s grace, so what the command forked (a watcher, a background server) dies with it instead of outliving the run. On POSIX, a run that dies before its teardown (killed, `SIGKILL` included) ends the group all the same: a watchdog outside the group holds a pipe from the run, and when the pipe closes it sends the group the same `SIGTERM`, then `SIGKILL`. Every served process is ended the same way, by the same code: a website's server, a mobile run's Appium server, a cli document's services. A mobile run's Appium server owns more than its group: the WebDriverAgent it starts for the simulator runs in a group of its own, and the teardown and the watchdog end it as well ([Mobile specs](../guides/mobile-specs.md#the-simulator-and-what-the-run-leaves-on-it)).
|
|
@@ -125,16 +125,16 @@ const integration = {
|
|
|
125
125
|
|
|
126
126
|
### What the preset sets
|
|
127
127
|
|
|
128
|
-
| Setting | Value | Why
|
|
129
|
-
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
130
|
-
| `test.testTimeout`, `hookTimeout` | `30_000` | vitest's 5 s never survived a container boot or a build
|
|
131
|
-
| `test.retry` | `0` | A flaky test is fixed or deleted; a retry hides it
|
|
132
|
-
| `test.restoreMocks`, `unstubGlobals`, `unstubEnvs` | `true` | A spy, a stubbed global or a stubbed env a test forgets to restore would fail a later file that did nothing wrong
|
|
133
|
-
| `test.exclude` | vitest's defaults + `.artifacts/**` + `**/{__expected__,__fixtures__,__requests__,__seeds__}/**` | What a test stands on is input, never a suite to run; `.artifacts/` holds copies of the project, a mutation sandbox among them
|
|
134
|
-
| `cacheDir`, `test.coverage.reportsDirectory`, `test.attachmentsDir`, `test.outputFile` | under `.artifacts/vitest/` | Everything a tool writes lives at `.artifacts/<tool>/` ([below](#where-a-run-writes))
|
|
135
|
-
| `test.globalSetup` | update mode, the run's temp root | Update mode writes after the run is reported; the temp root is [below](#where-a-run-writes)
|
|
136
|
-
| `test.maxWorkers` | the Vitest workers of the toolchain's capacity model
|
|
137
|
-
| `test.env.NODE_COMPILE_CACHE` | `.artifacts/node-compile-cache/`, as an absolute path | Every Node process a run starts reuses the code an earlier one compiled ([below](#where-a-run-writes))
|
|
128
|
+
| Setting | Value | Why |
|
|
129
|
+
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
130
|
+
| `test.testTimeout`, `hookTimeout` | `30_000` | vitest's 5 s never survived a container boot or a build |
|
|
131
|
+
| `test.retry` | `0` | A flaky test is fixed or deleted; a retry hides it |
|
|
132
|
+
| `test.restoreMocks`, `unstubGlobals`, `unstubEnvs` | `true` | A spy, a stubbed global or a stubbed env a test forgets to restore would fail a later file that did nothing wrong |
|
|
133
|
+
| `test.exclude` | vitest's defaults + `.artifacts/**` + `**/{__expected__,__fixtures__,__requests__,__seeds__}/**` | What a test stands on is input, never a suite to run; `.artifacts/` holds copies of the project, a mutation sandbox among them |
|
|
134
|
+
| `cacheDir`, `test.coverage.reportsDirectory`, `test.attachmentsDir`, `test.outputFile` | under `.artifacts/vitest/` | Everything a tool writes lives at `.artifacts/<tool>/` ([below](#where-a-run-writes)) |
|
|
135
|
+
| `test.globalSetup` | update mode, the run's temp root | Update mode writes after the run is reported; the temp root is [below](#where-a-run-writes) |
|
|
136
|
+
| `test.maxWorkers` | the Vitest workers of the toolchain's capacity model, read once and stated on every project | A run takes its share of the machine beside the gates running on it, and a run inside a gate's slice (`TYPESCRIPT_CAPACITY`) stays in it; Vitest evaluates the config again for a project with its own server and refuses one group of two worker counts, so a capacity that moves mid-run moves no project ([the toolchain's Quality checks § How many test runners](https://github.com/jterrazz/package-typescript/blob/main/docs/reference/quality-checks.md#how-many-test-runners)) |
|
|
137
|
+
| `test.env.NODE_COMPILE_CACHE` | `.artifacts/node-compile-cache/`, as an absolute path | Every Node process a run starts reuses the code an earlier one compiled ([below](#where-a-run-writes)) |
|
|
138
138
|
|
|
139
139
|
Coverage attaches to every process a run spawns (`autoAttachSubprocess`), and reads `dist/` only to map each bundle back to the source it was built from (`excludeAfterRemap`): a spec that runs the built command counts toward the sources it exercised. A coverage run compiles without the cache: vitest turns it off, since V8 stores a cached script without the source positions precise coverage reads.
|
|
140
140
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jterrazz/test",
|
|
3
|
-
"version": "18.0.
|
|
3
|
+
"version": "18.0.2",
|
|
4
4
|
"description": "Declarative testing framework for HTTP APIs, CLIs, background jobs, websites, mobile apps, rendered components and the rules a project ships: one entry point, real infrastructure and a real browser, golden-file assertions, plus an oxlint convention plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"api-testing",
|