@jterrazz/test 18.0.3 → 18.0.4

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,5 +1,5 @@
1
1
  import { i as writePendingRewrites } from "./shared-in-place.js";
2
- import { u as VITEST_ARTIFACTS_DIR } from "./shared-artifacts.js";
2
+ import { d as VITEST_ARTIFACTS_DIR } from "./shared-artifacts.js";
3
3
  import { t as setupEntry } from "./shared-setup-entry.js";
4
4
  import { join } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
package/dist/index.d.ts CHANGED
@@ -227,7 +227,7 @@ declare class CliResult extends BaseResult {
227
227
  get filesystem(): FilesystemAccessor;
228
228
  /** The requests the binary sent the declared backend during the run, in order, each `{ body, headers, method, url }`, the body parsed when JSON; empty without a backend. */
229
229
  get requests(): SentRequest[];
230
- /** A container the command spawned, by its name-label; `.exists` is `false` when none carries it. */
230
+ /** A container the command spawned, by its name-label; `.exists` is `false` when none carries it, and two carrying it are refused. */
231
231
  container(name: string): ContainerAccessor;
232
232
  /** All captured container IDs. Triggers the same lazy query as `container()`. */
233
233
  get containerIds(): string[];
package/dist/index.js CHANGED
@@ -572,19 +572,25 @@ var CliResult = class extends BaseResult {
572
572
  headers: { ...request.headers }
573
573
  }));
574
574
  }
575
- /** A container the command spawned, by its name-label; `.exists` is `false` when none carries it. */
575
+ /** A container the command spawned, by its name-label; `.exists` is `false` when none carries it, and two carrying it are refused. */
576
576
  container(name) {
577
- const captured = this.capturedContainers("container").get(name);
577
+ const [captured, ...others] = this.capturedContainers("container").get(name) ?? [];
578
578
  if (!captured) return containerAt(null, null, this.testDir, this.transform);
579
+ if (others.length > 0) throw new Error(refusal$1({
580
+ fix: "give each container of the run its own name-label value, or read them by id: `result.containerIds`, then `docker(id)`",
581
+ found: `CliResult.container('${name}'), which ${String(others.length + 1)} containers of the run carry: ${[captured, ...others].map(described).join(", ")}`,
582
+ read: "docs/guides/cli-specs.md#docker-aware-mode",
583
+ why: "A name reads one container, and two carrying it leave which one unsaid."
584
+ }));
579
585
  return containerAt(captured.id, captured.inspect, this.testDir, this.transform);
580
586
  }
581
587
  /** All captured container IDs. Triggers the same lazy query as `container()`. */
582
588
  get containerIds() {
583
- return [...this.capturedContainers("containerIds").values()].map((c) => c.id);
589
+ return [...this.capturedContainers("containerIds").values()].flat().map((c) => c.id);
584
590
  }
585
591
  async [Symbol.asyncDispose]() {
586
592
  if (!this.dockerConfig || this.testRunId === void 0) return;
587
- const ids = this.containersCache === null ? this.containers.labelled(this.dockerConfig.testRunLabel, this.testRunId) : [...this.containersCache.values()].map((c) => c.id);
593
+ const ids = this.containersCache === null ? this.containers.labelled(this.dockerConfig.testRunLabel, this.testRunId) : [...this.containersCache.values()].flat().map((c) => c.id);
588
594
  this.containers.remove(ids);
589
595
  }
590
596
  capturedContainers(member) {
@@ -606,15 +612,19 @@ var CliResult = class extends BaseResult {
606
612
  }
607
613
  const nameValue = labelsOf(inspect)[nameLabel];
608
614
  const key = typeof nameValue === "string" && nameValue.length > 0 ? nameValue : id;
609
- map.set(key, {
615
+ map.set(key, [...map.get(key) ?? [], {
610
616
  id,
611
617
  inspect
612
- });
618
+ }]);
613
619
  }
614
620
  this.containersCache = map;
615
621
  return map;
616
622
  }
617
623
  };
624
+ function described({ id, inspect }) {
625
+ const name = fieldOf(inspect, "Name");
626
+ return typeof name === "string" ? `${id} (${name.slice(1)})` : id;
627
+ }
618
628
  function labelsOf(inspect) {
619
629
  const labels = fieldOf(fieldOf(inspect, "Config", "config"), "Labels", "labels");
620
630
  return isRecord(labels) ? labels : {};
package/dist/services.js CHANGED
@@ -3,7 +3,7 @@ import { n as refusal, t as invariant } from "./shared-refusal.js";
3
3
  import { t as pause } from "./shared-pause.js";
4
4
  import { n as processService, t as ProcessHandle } from "./shared-process.adapter.js";
5
5
  import { n as requireBuiltPeer, t as loadPeer } from "./shared-peer.js";
6
- import { l as SQLITE_TEMPLATE_DIR } from "./shared-artifacts.js";
6
+ import { u as SQLITE_TEMPLATE_DIR } from "./shared-artifacts.js";
7
7
  import { t as SERVICE_IMAGES } from "./shared-service-images.js";
8
8
  import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeSync } from "node:fs";
9
9
  import { join, resolve } from "node:path";
@@ -1,6 +1,6 @@
1
1
  import { n as refusal } from "./shared-refusal.js";
2
2
  import { n as untilDeadline } from "./shared-pause.js";
3
- import { d as evidenceFile } from "./shared-artifacts.js";
3
+ import { f as evidenceFile } from "./shared-artifacts.js";
4
4
  import { n as SERVER_CALL_TIMEOUT_MS } from "./shared-session-ledger.js";
5
5
  import { c as scopeChain } from "./shared-landmarks.js";
6
6
  import { a as ambiguityError, c as asWrittenUnscoped, i as shortened, s as asWritten, t as scenarioFailure } from "./shared-scenario-failure.js";
@@ -6,6 +6,8 @@ import { tmpdir } from "node:os";
6
6
  const ARTIFACTS_DIR = ".artifacts";
7
7
  const VITEST_ARTIFACTS_DIR = `${ARTIFACTS_DIR}/vitest`;
8
8
  const COVERAGE_DIR = `${VITEST_ARTIFACTS_DIR}/coverage`;
9
+ /** Vitest's transformed-module cache, which it would share at the workspace root's `node_modules/.vitest-cache` between the members of a monorepo running side by side. */
10
+ const MODULE_CACHE_DIR = `${VITEST_ARTIFACTS_DIR}/modules`;
9
11
  const SQLITE_TEMPLATE_DIR = `${VITEST_ARTIFACTS_DIR}/sqlite`;
10
12
  /** Attachments and Browser Mode failure screenshots, which Vitest 5 would put in `.vitest/`. */
11
13
  const ATTACHMENTS_DIR = `${VITEST_ARTIFACTS_DIR}/attachments`;
@@ -29,6 +31,6 @@ const REPORTER_OUTPUT_FILES = {
29
31
  junit: `${VITEST_ARTIFACTS_DIR}/junit/output.xml`
30
32
  };
31
33
  //#endregion
32
- export { EVIDENCE_DIR as a, SCREENSHOTS_DIR as c, evidenceFile as d, COVERAGE_DIR as i, SQLITE_TEMPLATE_DIR as l, ATTACHMENTS_DIR as n, NODE_COMPILE_CACHE_DIR as o, BLOB_DIR as r, REPORTER_OUTPUT_FILES as s, ARTIFACTS_DIR as t, VITEST_ARTIFACTS_DIR as u };
34
+ export { EVIDENCE_DIR as a, REPORTER_OUTPUT_FILES as c, VITEST_ARTIFACTS_DIR as d, evidenceFile as f, COVERAGE_DIR as i, SCREENSHOTS_DIR as l, ATTACHMENTS_DIR as n, MODULE_CACHE_DIR as o, BLOB_DIR as r, NODE_COMPILE_CACHE_DIR as s, ARTIFACTS_DIR as t, SQLITE_TEMPLATE_DIR as u };
33
35
 
34
36
  //# sourceMappingURL=shared-artifacts.js.map
@@ -290,7 +290,7 @@ type ProcessOptions = {
290
290
  }>) => Record<string, string>) | Record<string, string>;
291
291
  /** Fixed port. Default: an OS-assigned free port, injected as `PORT`. */
292
292
  port?: number;
293
- /** Readiness: a path polled until any HTTP status answers (`PORT` injected), or a regex over the output whose one group captures the port. */
293
+ /** Readiness: a path polled until any HTTP status answers from the command's own process group (`PORT` injected), or a regex over the output whose one group captures the port. */
294
294
  ready?: RegExp | string;
295
295
  /** Readiness budget in milliseconds. Default 30 000. */
296
296
  timeout?: number;
@@ -1,5 +1,5 @@
1
1
  import { t as isRecord } from "./shared-json-shape.js";
2
- import { u as VITEST_ARTIFACTS_DIR } from "./shared-artifacts.js";
2
+ import { d as VITEST_ARTIFACTS_DIR } from "./shared-artifacts.js";
3
3
  import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
@@ -1,6 +1,6 @@
1
1
  import { n as refusal } from "./shared-refusal.js";
2
2
  import { n as untilDeadline } from "./shared-pause.js";
3
- import { d as evidenceFile } from "./shared-artifacts.js";
3
+ import { f as evidenceFile } from "./shared-artifacts.js";
4
4
  import { c as scopeChain } from "./shared-landmarks.js";
5
5
  import { n as CANDIDATE_LIMITS, o as ambiguousTargetError, r as candidatesOf, s as asWritten, t as scenarioFailure } from "./shared-scenario-failure.js";
6
6
  //#region src/specifications/website/browser/page-scripts.ts
@@ -3,7 +3,8 @@ import { n as untilDeadline } from "./shared-pause.js";
3
3
  import "./shared-listened-port.js";
4
4
  import "node:module";
5
5
  import { resolve } from "node:path";
6
- import { execSync, spawn, spawnSync } from "node:child_process";
6
+ import { execFile, execSync, spawn, spawnSync } from "node:child_process";
7
+ import { readFile, readdir, readlink } from "node:fs/promises";
7
8
  import { randomInt } from "node:crypto";
8
9
  import { Socket, connect } from "node:net";
9
10
  import { once } from "node:events";
@@ -20,6 +21,169 @@ var __exportAll = (all, no_symbols) => {
20
21
  return target;
21
22
  };
22
23
  //#endregion
24
+ //#region src/services/process/process-group.ts
25
+ const KILL_GRACE_MS = 2e3;
26
+ /** outside: Windows has no process groups to signal through a negative pid. */
27
+ const SIGNALS_GROUPS = process.platform !== "win32";
28
+ const GROUP_POLL_MS = 1e3;
29
+ const GROUP_SETTLE_MS = 50;
30
+ const STRAYS_SOURCE = `
31
+ const { execFileSync } = require('node:child_process');
32
+ const strays = JSON.parse(process.argv[1] ?? '[]');
33
+ const listed = (column) => {
34
+ let listing = '';
35
+ try { listing = execFileSync('ps', ['-A', '-o', 'pid=,' + column + '='], { encoding: 'utf8', maxBuffer: 67108864 }); } catch {}
36
+ return new Map(listing.split('\\n').flatMap((line) => {
37
+ const found = /^\\s*(\\d+)\\s(.*)$/u.exec(line);
38
+ return found === null ? [] : [[Number(found[1]), found[2]]];
39
+ }));
40
+ };
41
+ const names = (path, program) => path === program || path.endsWith('/' + program);
42
+ const strayPids = () => {
43
+ const commands = listed('command');
44
+ return [...listed('comm')]
45
+ .filter(([pid, executable]) => {
46
+ const command = commands.get(pid) ?? '';
47
+ return pid !== process.pid && strays.some((stray) =>
48
+ (names(executable, stray.program) || names(command.split(' ')[0], stray.program)) && stray.holds.every((word) => command.includes(word)));
49
+ })
50
+ .map(([pid]) => pid);
51
+ };
52
+ const signalStrays = (signal) => {
53
+ for (const pid of strays.length === 0 ? [] : strayPids()) {
54
+ try { process.kill(-pid, signal); } catch {}
55
+ try { process.kill(pid, signal); } catch {}
56
+ }
57
+ };
58
+ `;
59
+ /** Its arguments are the strays, then the group. With strays to reap, it outlives the group: a stray outlives the group that spawned it. */
60
+ const WATCHDOG = `${STRAYS_SOURCE}
61
+ const group = -Number(process.argv[2]);
62
+ const gone = () => { try { process.kill(group, 0); return false; } catch { return true; } };
63
+ const end = () => {
64
+ try { process.kill(group, 'SIGTERM'); } catch { if (strays.length === 0) process.exit(0); }
65
+ signalStrays('SIGTERM');
66
+ setTimeout(() => { try { process.kill(group, 'SIGKILL'); } catch {} signalStrays('SIGKILL'); process.exit(0); }, ${KILL_GRACE_MS});
67
+ };
68
+ process.stdin.on('end', end);
69
+ process.stdin.on('error', end);
70
+ process.stdin.resume();
71
+ setInterval(() => { if (strays.length === 0 && gone()) process.exit(0); }, ${GROUP_POLL_MS});
72
+ `;
73
+ /** Its argument is the strays: each gets SIGTERM, and SIGKILL when it outlives the grace. */
74
+ const REAPER = `${STRAYS_SOURCE}
75
+ signalStrays('SIGTERM');
76
+ const deadline = Date.now() + ${KILL_GRACE_MS};
77
+ const settle = () => {
78
+ if (strayPids().length === 0) return;
79
+ if (Date.now() >= deadline) { signalStrays('SIGKILL'); return; }
80
+ setTimeout(settle, ${GROUP_SETTLE_MS});
81
+ };
82
+ settle();
83
+ `;
84
+ function groupRunning(group) {
85
+ try {
86
+ process.kill(group, 0);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+ async function groupEnded(group, withinMs) {
93
+ return await untilDeadline(() => !groupRunning(group) || void 0, {
94
+ interval: GROUP_SETTLE_MS,
95
+ timeout: withinMs
96
+ }) === true;
97
+ }
98
+ /** Resolves once the child is reaped, at once for one reaped already. */
99
+ async function reaped(child) {
100
+ if (child.exitCode !== null || child.signalCode !== null) return;
101
+ await once(child, "exit");
102
+ }
103
+ /** The child's whole group where groups are signalled, the child alone where they are not or the group is gone; nothing for a child that never started. */
104
+ function signalGroup(child, signal = "SIGTERM") {
105
+ if (child.pid === void 0) return false;
106
+ if (SIGNALS_GROUPS) try {
107
+ process.kill(-child.pid, signal);
108
+ return true;
109
+ } catch {}
110
+ return child.kill(signal);
111
+ }
112
+ async function endGroup(child) {
113
+ if (child.pid === void 0) return;
114
+ const leaderReaped = reaped(child);
115
+ signalGroup(child, "SIGTERM");
116
+ if (!await groupEnded(-child.pid, 2e3)) signalGroup(child, "SIGKILL");
117
+ await leaderReaped;
118
+ }
119
+ //#endregion
120
+ //#region src/services/process/listener-owner.ts
121
+ function ownsFromLsof(output, group) {
122
+ return owns(output.split("\n").filter((line) => line.startsWith("g")).map((line) => line.slice(1)), /* @__PURE__ */ new Set([String(group)]));
123
+ }
124
+ /** Owned when a listener is the group's and none it can see is outside it. */
125
+ function owns(listeners, owned) {
126
+ return listeners.length > 0 && listeners.every((listener) => owned.has(listener));
127
+ }
128
+ const LISTEN_STATE = "0A";
129
+ function listeningInodes(table, port) {
130
+ return table.split("\n").map((row) => row.trim().split(/\s+/u)).filter(([, local, , state]) => {
131
+ const [, hex] = String(local).split(":");
132
+ return state === LISTEN_STATE && Number.parseInt(String(hex), 16) === port;
133
+ }).map((fields) => String(fields[9]));
134
+ }
135
+ function processGroupOf(stat) {
136
+ return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[2]);
137
+ }
138
+ function socketInode(link) {
139
+ return /^socket:\[(?<inode>\d+)\]$/u.exec(link)?.groups?.inode;
140
+ }
141
+ const NODE_PROC = {
142
+ entries: async (path) => await readdir(path).catch(() => []),
143
+ link: async (path) => await readlink(path).catch(() => ""),
144
+ text: async (path) => await readFile(path, "utf8").catch(() => "")
145
+ };
146
+ async function groupInodes(group, proc) {
147
+ const pids = await proc.entries("/proc");
148
+ const owned = /* @__PURE__ */ new Set();
149
+ for (const pid of pids) {
150
+ if (processGroupOf(await proc.text(`/proc/${pid}/stat`)) !== group) continue;
151
+ for (const fd of await proc.entries(`/proc/${pid}/fd`)) {
152
+ const inode = socketInode(await proc.link(`/proc/${pid}/fd/${fd}`));
153
+ if (inode !== void 0) owned.add(inode);
154
+ }
155
+ }
156
+ return owned;
157
+ }
158
+ /** Linux: the listening sockets of `/proc/net/tcp` and `tcp6` on the port, against the sockets the group's processes hold open. */
159
+ async function ownsOnLinux(port, group, proc = NODE_PROC) {
160
+ return owns((await Promise.all(["/proc/net/tcp", "/proc/net/tcp6"].map(proc.text))).flatMap((table) => listeningInodes(table, port)), await groupInodes(group, proc));
161
+ }
162
+ /** macOS: the groups `lsof` names for the port's listeners; undefined where `program` cannot run. */
163
+ async function ownsOnDarwin(port, group, program = "lsof") {
164
+ const listed = await new Promise((resolve) => {
165
+ execFile(program, [
166
+ "-nP",
167
+ "-a",
168
+ `-iTCP:${String(port)}`,
169
+ "-sTCP:LISTEN",
170
+ "-Fpg"
171
+ ], (error, stdout) => {
172
+ resolve(error === null || error.code === 1 ? stdout : void 0);
173
+ });
174
+ });
175
+ return listed === void 0 ? void 0 : ownsFromLsof(listed, group);
176
+ }
177
+ /** Whether `port` is listened on by the process group `group` alone, as far as this process can see; undefined where the platform cannot tell. */
178
+ async function groupOwnsPort(port, group, platform = process.platform) {
179
+ if (platform === "linux") return await ownsOnLinux(port, group);
180
+ if (platform === "darwin") return await ownsOnDarwin(port, group);
181
+ }
182
+ async function childOwnsPort(port, child) {
183
+ const group = SIGNALS_GROUPS ? child?.pid : void 0;
184
+ return group === void 0 ? void 0 : await groupOwnsPort(port, group);
185
+ }
186
+ //#endregion
23
187
  //#region src/services/process/listening.ts
24
188
  async function firstAnswering(urls) {
25
189
  return (await Promise.all(urls.map(async (url) => await answers(url) ? url : void 0))).find((url) => url !== void 0);
@@ -81,6 +245,13 @@ function lessonOf(reason, output, port) {
81
245
  why: "A process is handed a free port unless `port` pins one, and a port another process holds is not free."
82
246
  };
83
247
  }
248
+ function foreignAnswer(port) {
249
+ return {
250
+ fix: "stop the process holding that port, or announce the port with a `ready` pattern if the server runs outside the command (a container, a daemon)",
251
+ found: `port ${String(port)} answered, and a process the command did not start listens on it`,
252
+ why: "A process is ready once its own server answers, and an answer from another process on its port is not one."
253
+ };
254
+ }
84
255
  async function refuseHeldPort(port, specification, command) {
85
256
  if (port === void 0) return;
86
257
  const host = await holdingHost(port);
@@ -156,102 +327,6 @@ async function reservePort(pinned, pick = draw) {
156
327
  };
157
328
  }
158
329
  //#endregion
159
- //#region src/services/process/process-group.ts
160
- const KILL_GRACE_MS = 2e3;
161
- /** outside: Windows has no process groups to signal through a negative pid. */
162
- const SIGNALS_GROUPS = process.platform !== "win32";
163
- const GROUP_POLL_MS = 1e3;
164
- const GROUP_SETTLE_MS = 50;
165
- const STRAYS_SOURCE = `
166
- const { execFileSync } = require('node:child_process');
167
- const strays = JSON.parse(process.argv[1] ?? '[]');
168
- const listed = (column) => {
169
- let listing = '';
170
- try { listing = execFileSync('ps', ['-A', '-o', 'pid=,' + column + '='], { encoding: 'utf8', maxBuffer: 67108864 }); } catch {}
171
- return new Map(listing.split('\\n').flatMap((line) => {
172
- const found = /^\\s*(\\d+)\\s(.*)$/u.exec(line);
173
- return found === null ? [] : [[Number(found[1]), found[2]]];
174
- }));
175
- };
176
- const names = (path, program) => path === program || path.endsWith('/' + program);
177
- const strayPids = () => {
178
- const commands = listed('command');
179
- return [...listed('comm')]
180
- .filter(([pid, executable]) => {
181
- const command = commands.get(pid) ?? '';
182
- return pid !== process.pid && strays.some((stray) =>
183
- (names(executable, stray.program) || names(command.split(' ')[0], stray.program)) && stray.holds.every((word) => command.includes(word)));
184
- })
185
- .map(([pid]) => pid);
186
- };
187
- const signalStrays = (signal) => {
188
- for (const pid of strays.length === 0 ? [] : strayPids()) {
189
- try { process.kill(-pid, signal); } catch {}
190
- try { process.kill(pid, signal); } catch {}
191
- }
192
- };
193
- `;
194
- /** Its arguments are the strays, then the group. With strays to reap, it outlives the group: a stray outlives the group that spawned it. */
195
- const WATCHDOG = `${STRAYS_SOURCE}
196
- const group = -Number(process.argv[2]);
197
- const gone = () => { try { process.kill(group, 0); return false; } catch { return true; } };
198
- const end = () => {
199
- try { process.kill(group, 'SIGTERM'); } catch { if (strays.length === 0) process.exit(0); }
200
- signalStrays('SIGTERM');
201
- setTimeout(() => { try { process.kill(group, 'SIGKILL'); } catch {} signalStrays('SIGKILL'); process.exit(0); }, ${KILL_GRACE_MS});
202
- };
203
- process.stdin.on('end', end);
204
- process.stdin.on('error', end);
205
- process.stdin.resume();
206
- setInterval(() => { if (strays.length === 0 && gone()) process.exit(0); }, ${GROUP_POLL_MS});
207
- `;
208
- /** Its argument is the strays: each gets SIGTERM, and SIGKILL when it outlives the grace. */
209
- const REAPER = `${STRAYS_SOURCE}
210
- signalStrays('SIGTERM');
211
- const deadline = Date.now() + ${KILL_GRACE_MS};
212
- const settle = () => {
213
- if (strayPids().length === 0) return;
214
- if (Date.now() >= deadline) { signalStrays('SIGKILL'); return; }
215
- setTimeout(settle, ${GROUP_SETTLE_MS});
216
- };
217
- settle();
218
- `;
219
- function groupRunning(group) {
220
- try {
221
- process.kill(group, 0);
222
- return true;
223
- } catch {
224
- return false;
225
- }
226
- }
227
- async function groupEnded(group, withinMs) {
228
- return await untilDeadline(() => !groupRunning(group) || void 0, {
229
- interval: GROUP_SETTLE_MS,
230
- timeout: withinMs
231
- }) === true;
232
- }
233
- /** Resolves once the child is reaped, at once for one reaped already. */
234
- async function reaped(child) {
235
- if (child.exitCode !== null || child.signalCode !== null) return;
236
- await once(child, "exit");
237
- }
238
- /** The child's whole group where groups are signalled, the child alone where they are not or the group is gone; nothing for a child that never started. */
239
- function signalGroup(child, signal = "SIGTERM") {
240
- if (child.pid === void 0) return false;
241
- if (SIGNALS_GROUPS) try {
242
- process.kill(-child.pid, signal);
243
- return true;
244
- } catch {}
245
- return child.kill(signal);
246
- }
247
- async function endGroup(child) {
248
- if (child.pid === void 0) return;
249
- const leaderReaped = reaped(child);
250
- signalGroup(child, "SIGTERM");
251
- if (!await groupEnded(-child.pid, 2e3)) signalGroup(child, "SIGKILL");
252
- await leaderReaped;
253
- }
254
- //#endregion
255
330
  //#region src/services/process/serve.ts
256
331
  var serve_exports = /* @__PURE__ */ __exportAll({
257
332
  ServedProcess: () => ServedProcess,
@@ -299,7 +374,7 @@ function runToCompletion(command, env) {
299
374
  stdio: "pipe"
300
375
  });
301
376
  }
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. */
377
+ /** Any HTTP status counts as ready once a process of the child's own group listens on the port, and none outside it: the port it is handed is reserved for it until `stop()`, so no other served process is handed it, and an unrelated server that took it meanwhile is refused rather than read 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. */
303
378
  var ServedProcess = class {
304
379
  port = null;
305
380
  child = null;
@@ -411,11 +486,16 @@ var ServedProcess = class {
411
486
  async awaitAnswer(port) {
412
487
  const baseUrls = [`http://127.0.0.1:${port}`, `http://[::1]:${port}`];
413
488
  const path = typeof this.options.ready === "string" ? this.options.ready : "/";
414
- const seen = await untilDeadline(async () => this.exited ? "exited" : await firstAnswering(baseUrls.map((baseUrl) => `${baseUrl}${path}`)), {
489
+ const seen = await untilDeadline(async () => {
490
+ if (this.exited) return "exited";
491
+ const answered = await firstAnswering(baseUrls.map((baseUrl) => `${baseUrl}${path}`));
492
+ return (answered === void 0 || await childOwnsPort(port, this.child)) === false ? "foreign" : answered;
493
+ }, {
415
494
  interval: READY_POLL_INTERVAL_MS,
416
495
  timeout: this.timeout()
417
496
  });
418
497
  if (seen === "exited") return null;
498
+ if (seen === "foreign") return await this.refuse(foreignAnswer(port));
419
499
  if (seen === void 0) return await this.refuse({
420
500
  fix: "correct `ready` to a path the server answers, have it listen on `PORT`, or raise `timeout`",
421
501
  found: `the server did not answer on ${baseUrls.map((baseUrl) => `${baseUrl}${path}`).join(" or ")} within ${this.timeout()}ms, whatever address it printed above`,
@@ -1,7 +1,7 @@
1
1
  import { t as ServedProcess } from "./shared-serve.js";
2
2
  import { t as isRecord } from "./shared-json-shape.js";
3
3
  import { n as refusal } from "./shared-refusal.js";
4
- import { u as VITEST_ARTIFACTS_DIR } from "./shared-artifacts.js";
4
+ import { d as VITEST_ARTIFACTS_DIR } from "./shared-artifacts.js";
5
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { join, resolve } from "node:path";
7
7
  import { execFile } from "node:child_process";
package/dist/vitest.js CHANGED
@@ -3,7 +3,7 @@ import { t as isRecord } from "./shared-json-shape.js";
3
3
  import { n as refusal$1, t as invariant } from "./shared-refusal.js";
4
4
  import { i as renderText, n as literate, o as resolveSpecPath } from "./shared-literate-plugin.js";
5
5
  import { u as shouldUpdateSnapshots } from "./shared-ledger.js";
6
- import { c as SCREENSHOTS_DIR, i as COVERAGE_DIR, n as ATTACHMENTS_DIR, o as NODE_COMPILE_CACHE_DIR, r as BLOB_DIR, s as REPORTER_OUTPUT_FILES, t as ARTIFACTS_DIR, u as VITEST_ARTIFACTS_DIR } from "./shared-artifacts.js";
6
+ import { c as REPORTER_OUTPUT_FILES, d as VITEST_ARTIFACTS_DIR, i as COVERAGE_DIR, l as SCREENSHOTS_DIR, n as ATTACHMENTS_DIR, o as MODULE_CACHE_DIR, r as BLOB_DIR, s as NODE_COMPILE_CACHE_DIR, t as ARTIFACTS_DIR } from "./shared-artifacts.js";
7
7
  import { t as derivedProjects } from "./shared-derived-projects.js";
8
8
  import { t as TEST_SECTION } from "./shared-test-section.js";
9
9
  import { n as servicesSetup } from "./shared-services.global-setup.js";
@@ -442,6 +442,7 @@ function rootDefaults() {
442
442
  return mergeConfig(projectDefaults(), { test: {
443
443
  coverage: COVERAGE,
444
444
  env: { NODE_COMPILE_CACHE: resolve(NODE_COMPILE_CACHE_DIR) },
445
+ fsModuleCachePath: MODULE_CACHE_DIR,
445
446
  maxWorkers: slotsFor(resolve("."), "vitest"),
446
447
  outputFile: reporterOutputFiles(process.argv)
447
448
  } });
@@ -536,7 +536,7 @@ test('destroy removes the container created in an earlier run', async () => {
536
536
  });
537
537
  ```
538
538
 
539
- Container accessor surface: `exists`, `running`/`toBeRunning`, `status`, `id`, `file(path)` (content read inside the container), `exec(cmd)` (returns stdout/stderr), `stdout`/`stderr` (container logs). Looking up an absent container returns `exists: false` instead of throwing. `result.containerIds` lists every container captured for the run.
539
+ Container accessor surface: `exists`, `running`/`toBeRunning`, `status`, `id`, `file(path)` (content read inside the container), `exec(cmd)` (returns stdout/stderr), `stdout`/`stderr` (container logs). Looking up an absent container returns `exists: false` instead of throwing, and a name two containers of the run carry is refused ([Assertions § Container subjects](../reference/assertions.md#container-subjects-docker-aware-cli)). `result.containerIds` lists every container captured for the run.
540
540
 
541
541
  The runner handle also destructures to `{ cli, cleanup, docker }`. The `docker(containerId)` reader is the escape hatch for reading an **arbitrary** container by raw id (e.g. one a follow-up command referenced): it lazily runs `docker inspect` and returns the same `ContainerAccessor` type, so `await expect(docker(id)).toBeRunning()` and the sync reads work identically. Unlike `result.container(name)` it does not need `nameLabel`: it looks up by id directly; an unknown id yields `exists: false`.
542
542
 
@@ -224,7 +224,7 @@ Same semantics as `result.directory(...)`, rooted at the cwd itself. `.files()`
224
224
 
225
225
  ## Container subjects (docker-aware cli)
226
226
 
227
- Available when the runner declares `docker: { envVar, nameLabel, testRunLabel }`; results must be bound with `await using` (rule `docker-exec-bound-with-await-using`). `result.container(name)` looks a container up by its `nameLabel` value, lazily: no Docker call happens until you touch it. Absent containers return `exists: false` instead of throwing.
227
+ Available when the runner declares `docker: { envVar, nameLabel, testRunLabel }`; results must be bound with `await using` (rule `docker-exec-bound-with-await-using`). `result.container(name)` looks a container up by its `nameLabel` value, lazily: no Docker call happens until you touch it. Absent containers return `exists: false` instead of throwing; a name two containers of the run carry is refused, naming each candidate by id and docker name, since reading either would be a guess.
228
228
 
229
229
  The runner handle itself also exposes a `docker(containerId)` reader (returned by `specification.api()` and `specification.cli()`, not `specification.job()`): given a raw container id, it lazily runs `docker inspect` and returns the **same** `ContainerAccessor` type, so it works with `await expect(spec.docker(id)).toBeRunning()` and every read accessor below. An unknown id yields an accessor with `exists: false` rather than throwing. The app of an api spec runs in the test process, but it can still start containers through the Docker daemon, as an API that deploys or provisions something does: the response hands back an id, and `docker(id)` reads the container the request left behind.
230
230
 
@@ -84,7 +84,7 @@ export const { cleanup, website } = await specification.website({
84
84
 
85
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
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.
87
+ **Any status the `ready` path answers is the server's, once the server is proved to be the one answering.** On Linux and macOS an answer counts only while every listener of the port this run can see is in the command's process group: a process outside it (one started by hand on that very number while the command starts, a server the command detached, a container's port forward) is refused at once, naming the port, never read as ready. A server that rightly runs outside the group announces its port instead, with a `ready` pattern. Elsewhere the answer is taken as the server's.
88
88
 
89
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) => ...`.
90
90
 
@@ -172,13 +172,14 @@ export default await oxlintConfig(import.meta.url);
172
172
 
173
173
  **In the project, only under `.artifacts/`**, one folder per tool, one line in `.gitignore`, one `rm -rf .artifacts` for a clean slate:
174
174
 
175
- | Path | Written by | Lifetime |
176
- | ------------------------------------------------ | ---------------------------------------------------------- | ------------------------------- |
177
- | `.artifacts/vitest/` | vite's transform cache, vitest's reports | Reused across runs |
178
- | `.artifacts/vitest/coverage/` | the coverage provider | Rewritten per coverage run |
179
- | `.artifacts/vitest/sqlite/template-<key>.sqlite` | `sqlite()`'s schema template | Reused until the schema changes |
180
- | `.artifacts/vitest/evidence/<pid>-.../` | the screenshot of a website or mobile scenario that failed | Kept until the next run starts |
181
- | `.artifacts/node-compile-cache/` | Node, for the workers and every process they spawn | Reused across runs |
175
+ | Path | Written by | Lifetime |
176
+ | ------------------------------------------------ | --------------------------------------------------------------- | ------------------------------- |
177
+ | `.artifacts/vitest/` | vite's transform cache, vitest's reports | Reused across runs |
178
+ | `.artifacts/vitest/coverage/` | the coverage provider | Rewritten per coverage run |
179
+ | `.artifacts/vitest/modules/` | vitest's transformed-module cache, one per member of a monorepo | Reused across runs |
180
+ | `.artifacts/vitest/sqlite/template-<key>.sqlite` | `sqlite()`'s schema template | Reused until the schema changes |
181
+ | `.artifacts/vitest/evidence/<pid>-.../` | the screenshot of a website or mobile scenario that failed | Kept until the next run starts |
182
+ | `.artifacts/node-compile-cache/` | Node, for the workers and every process they spawn | Reused across runs |
182
183
 
183
184
  **Everything else is scratch, in one temp folder per run.** Before the first worker starts, the preset creates `jterrazz-test-runs-<uid>/<pid>-...` in the OS temp directory and points `TMPDIR`, `TMP` and `TEMP` at it, so every temp folder a worker, a spawned child or a tool it runs makes lands inside. That holds each CLI spec's working directory, the per-worker database copies and a browser's profile. The run removes the folder when it ends, and the next run removes the folder of any run that died first. A CLI spec's working directory goes sooner, once its test has finished, except under `test.concurrent`, where it waits for the run.
184
185
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/test",
3
- "version": "18.0.3",
3
+ "version": "18.0.4",
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",