@jterrazz/test 18.0.0 → 18.0.1

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.
@@ -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 { t as listenedPort } from "./shared-listened-port.js";
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 { Socket, connect, createServer } from "node:net";
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.options.port ?? await findFreePort();
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;
@@ -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 OS assigns; a port 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) |
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)).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/test",
3
- "version": "18.0.0",
3
+ "version": "18.0.1",
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",