@untestutils/core 0.5.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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/dist/artifact-store.d.mts +19 -0
  3. package/dist/artifact-store.d.ts +20 -0
  4. package/dist/artifact-store.d.ts.map +1 -0
  5. package/dist/artifact-store.mjs +52 -0
  6. package/dist/debug.d.mts +5 -0
  7. package/dist/debug.d.ts +6 -0
  8. package/dist/debug.d.ts.map +1 -0
  9. package/dist/debug.mjs +22 -0
  10. package/dist/drivers/cli-framework.d.mts +57 -0
  11. package/dist/drivers/cli-framework.d.ts +64 -0
  12. package/dist/drivers/cli-framework.d.ts.map +1 -0
  13. package/dist/drivers/cli-framework.mjs +138 -0
  14. package/dist/drivers/command.d.mts +15 -0
  15. package/dist/drivers/command.d.ts +16 -0
  16. package/dist/drivers/command.d.ts.map +1 -0
  17. package/dist/drivers/command.mjs +70 -0
  18. package/dist/drivers/define-driver.d.mts +4 -0
  19. package/dist/drivers/define-driver.d.ts +5 -0
  20. package/dist/drivers/define-driver.d.ts.map +1 -0
  21. package/dist/drivers/define-driver.mjs +3 -0
  22. package/dist/drivers/host.d.mts +18 -0
  23. package/dist/drivers/host.d.ts +19 -0
  24. package/dist/drivers/host.d.ts.map +1 -0
  25. package/dist/drivers/host.mjs +31 -0
  26. package/dist/drivers/index.d.mts +12 -0
  27. package/dist/drivers/index.d.ts +13 -0
  28. package/dist/drivers/index.d.ts.map +1 -0
  29. package/dist/drivers/index.mjs +6 -0
  30. package/dist/drivers/node-entry.d.mts +13 -0
  31. package/dist/drivers/node-entry.d.ts +14 -0
  32. package/dist/drivers/node-entry.d.ts.map +1 -0
  33. package/dist/drivers/node-entry.mjs +55 -0
  34. package/dist/drivers/static-dir.d.mts +9 -0
  35. package/dist/drivers/static-dir.d.ts +10 -0
  36. package/dist/drivers/static-dir.d.ts.map +1 -0
  37. package/dist/drivers/static-dir.mjs +90 -0
  38. package/dist/harness.d.mts +4 -0
  39. package/dist/harness.d.ts +5 -0
  40. package/dist/harness.d.ts.map +1 -0
  41. package/dist/harness.mjs +18 -0
  42. package/dist/hash.d.mts +6 -0
  43. package/dist/hash.d.ts +7 -0
  44. package/dist/hash.d.ts.map +1 -0
  45. package/dist/hash.mjs +76 -0
  46. package/dist/identity.d.mts +9 -0
  47. package/dist/identity.d.ts +10 -0
  48. package/dist/identity.d.ts.map +1 -0
  49. package/dist/identity.mjs +44 -0
  50. package/dist/index.d.mts +29 -0
  51. package/dist/index.d.ts +30 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.mjs +22 -0
  54. package/dist/lock.d.mts +26 -0
  55. package/dist/lock.d.ts +27 -0
  56. package/dist/lock.d.ts.map +1 -0
  57. package/dist/lock.mjs +145 -0
  58. package/dist/orchestrator.d.mts +19 -0
  59. package/dist/orchestrator.d.ts +22 -0
  60. package/dist/orchestrator.d.ts.map +1 -0
  61. package/dist/orchestrator.mjs +343 -0
  62. package/dist/paths.d.mts +20 -0
  63. package/dist/paths.d.ts +21 -0
  64. package/dist/paths.d.ts.map +1 -0
  65. package/dist/paths.mjs +124 -0
  66. package/dist/ports.d.mts +2 -0
  67. package/dist/ports.d.ts +8 -0
  68. package/dist/ports.d.ts.map +1 -0
  69. package/dist/ports.mjs +38 -0
  70. package/dist/process.d.mts +31 -0
  71. package/dist/process.d.ts +36 -0
  72. package/dist/process.d.ts.map +1 -0
  73. package/dist/process.mjs +191 -0
  74. package/dist/progress.d.mts +45 -0
  75. package/dist/progress.d.ts +66 -0
  76. package/dist/progress.d.ts.map +1 -0
  77. package/dist/progress.mjs +205 -0
  78. package/dist/ready.d.mts +11 -0
  79. package/dist/ready.d.ts +12 -0
  80. package/dist/ready.d.ts.map +1 -0
  81. package/dist/ready.mjs +142 -0
  82. package/dist/recipes.d.mts +23 -0
  83. package/dist/recipes.d.ts +24 -0
  84. package/dist/recipes.d.ts.map +1 -0
  85. package/dist/recipes.mjs +94 -0
  86. package/dist/run-helper.d.mts +6 -0
  87. package/dist/run-helper.d.ts +7 -0
  88. package/dist/run-helper.d.ts.map +1 -0
  89. package/dist/run-helper.mjs +79 -0
  90. package/dist/target-registry.d.mts +27 -0
  91. package/dist/target-registry.d.ts +28 -0
  92. package/dist/target-registry.d.ts.map +1 -0
  93. package/dist/target-registry.mjs +103 -0
  94. package/dist/types.d.mts +92 -0
  95. package/dist/types.d.ts +93 -0
  96. package/dist/types.d.ts.map +1 -0
  97. package/dist/types.mjs +1 -0
  98. package/package.json +54 -0
package/dist/lock.mjs ADDED
@@ -0,0 +1,145 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "pathe";
4
+ import { debug } from "./debug.mjs";
5
+ const DEFAULT_STALE_MS = 10 * 60 * 1e3;
6
+ const HEARTBEAT_MS = 5e3;
7
+ function isPidAlive(pid) {
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+ export class FileLock {
16
+ lockPath;
17
+ identity;
18
+ staleMs;
19
+ heartbeat;
20
+ held = false;
21
+ constructor(lockPath, identity, staleMs = DEFAULT_STALE_MS) {
22
+ this.lockPath = lockPath;
23
+ this.identity = identity;
24
+ this.staleMs = staleMs;
25
+ }
26
+ async acquire(timeoutMs = 12e4) {
27
+ await mkdir(dirname(this.lockPath), { recursive: true });
28
+ const deadline = Date.now() + timeoutMs;
29
+ while (Date.now() < deadline) {
30
+ try {
31
+ const handle = await open(this.lockPath, "wx");
32
+ const info = {
33
+ pid: process.pid,
34
+ startedAt: Date.now(),
35
+ heartbeatAt: Date.now(),
36
+ identity: this.identity
37
+ };
38
+ await handle.writeFile(JSON.stringify(info), "utf8");
39
+ await handle.close();
40
+ this.held = true;
41
+ this.startHeartbeat();
42
+ debug("lock", `acquired ${this.lockPath}`);
43
+ return;
44
+ } catch (error) {
45
+ const code = error.code;
46
+ if (code !== "EEXIST") throw error;
47
+
48
+ if (await this.tryStealStale()) continue;
49
+ await sleep(200);
50
+ }
51
+ }
52
+ throw new Error(`[untestutils] timed out waiting for lock: ${this.lockPath}`);
53
+ }
54
+ async release() {
55
+ this.stopHeartbeat();
56
+ if (!this.held) return;
57
+ try {
58
+ const raw = await readFile(this.lockPath, "utf8");
59
+ const info = JSON.parse(raw);
60
+ if (info.pid === process.pid) {
61
+ await unlink(this.lockPath);
62
+ }
63
+ } catch {}
64
+ this.held = false;
65
+ debug("lock", `released ${this.lockPath}`);
66
+ }
67
+ startHeartbeat() {
68
+ this.heartbeat = setInterval(() => {
69
+ void this.beat();
70
+ }, HEARTBEAT_MS);
71
+ this.heartbeat.unref?.();
72
+ }
73
+ stopHeartbeat() {
74
+ if (this.heartbeat) clearInterval(this.heartbeat);
75
+ this.heartbeat = undefined;
76
+ }
77
+ async beat() {
78
+ try {
79
+ const info = {
80
+ pid: process.pid,
81
+ startedAt: Date.now(),
82
+ heartbeatAt: Date.now(),
83
+ identity: this.identity
84
+ };
85
+
86
+ try {
87
+ const prev = JSON.parse(await readFile(this.lockPath, "utf8"));
88
+ info.startedAt = prev.startedAt;
89
+ } catch {}
90
+ await writeFile(this.lockPath, JSON.stringify(info), "utf8");
91
+ } catch {}
92
+ }
93
+ async tryStealStale() {
94
+ try {
95
+ const raw = await readFile(this.lockPath, "utf8");
96
+ const info = JSON.parse(raw);
97
+ const staleByTime = Date.now() - (info.heartbeatAt || info.startedAt) > this.staleMs;
98
+ const dead = !isPidAlive(info.pid);
99
+ if (staleByTime || dead) {
100
+ debug("lock", `stealing stale lock pid=${info.pid} dead=${dead}`);
101
+ await unlink(this.lockPath);
102
+ return true;
103
+ }
104
+ } catch {
105
+ try {
106
+ const s = await stat(this.lockPath);
107
+ if (Date.now() - s.mtimeMs > this.staleMs) {
108
+ await unlink(this.lockPath);
109
+ return true;
110
+ }
111
+ } catch {}
112
+ }
113
+ return false;
114
+ }
115
+ }
116
+ export async function atomicWriteJson(path, data) {
117
+ await mkdir(dirname(path), { recursive: true });
118
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
119
+ await writeFile(tmp, JSON.stringify(data, null, 2), "utf8");
120
+ await rename(tmp, path);
121
+ }
122
+ export async function atomicWriteText(path, content) {
123
+ await mkdir(dirname(path), { recursive: true });
124
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
125
+ await writeFile(tmp, content, "utf8");
126
+ await rename(tmp, path);
127
+ }
128
+ export async function markReady(readyPath) {
129
+ await atomicWriteText(readyPath, String(Date.now()));
130
+ }
131
+ export async function isReady(readyPath) {
132
+ return existsSync(readyPath);
133
+ }
134
+ export async function removePath(path) {
135
+ await rm(path, {
136
+ recursive: true,
137
+ force: true
138
+ });
139
+ }
140
+ function sleep(ms) {
141
+ return new Promise((r) => setTimeout(r, ms));
142
+ }
143
+ export function lockPathFor(artifactsRoot, identity) {
144
+ return join(artifactsRoot, "locks", `${identity}.lock`);
145
+ }
@@ -0,0 +1,19 @@
1
+ import type { Recipe, Running } from "./types.mjs";
2
+ export interface OrchestratorOptions {
3
+ artifactsRoot?: string;
4
+ cwd?: string;
5
+ }
6
+ export interface PreparedTarget {
7
+ id: string;
8
+ identity: string;
9
+ hash: string;
10
+ outDir: string;
11
+ running: Running;
12
+ recipe: Recipe;
13
+ }
14
+ export declare function resolveRecipe(input: string | Recipe): Promise<Recipe>;
15
+ export declare function ensurePrepared(recipeInput: string | Recipe, opts?: OrchestratorOptions): Promise<PreparedTarget>;
16
+ export declare function stopAllTargets(artifactsRoot?: string): Promise<void>;
17
+ /** Kill leftover registry servers from a previous crashed run (call at globalSetup start). */
18
+ export declare function reclaimStaleTargets(artifactsRoot?: string): Promise<void>;
19
+ export declare function createHarnessHandle(prepared: PreparedTarget): import("./types").HarnessHandle;
@@ -0,0 +1,22 @@
1
+ import type { Recipe, Running } from './types';
2
+ export interface OrchestratorOptions {
3
+ artifactsRoot?: string;
4
+ cwd?: string;
5
+ }
6
+ export interface PreparedTarget {
7
+ id: string;
8
+ identity: string;
9
+ hash: string;
10
+ outDir: string;
11
+ running: Running;
12
+ recipe: Recipe;
13
+ }
14
+ export declare function resolveRecipe(input: string | Recipe): Promise<Recipe>;
15
+ export declare function ensurePrepared(recipeInput: string | Recipe, opts?: OrchestratorOptions): Promise<PreparedTarget>;
16
+ export declare function stopAllTargets(artifactsRoot?: string): Promise<void>;
17
+ /** Kill leftover registry servers from a previous crashed run (call at globalSetup start). */
18
+ export declare function reclaimStaleTargets(artifactsRoot?: string): Promise<void>;
19
+ /** @internal clear in-process maps without stopping servers (coverage of registry reuse). */
20
+ export declare function detachLiveTargetsForTests(): void;
21
+ export declare function createHarnessHandle(prepared: PreparedTarget): import('./types').HarnessHandle;
22
+ //# sourceMappingURL=orchestrator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../src/orchestrator.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAAe,MAAM,SAAS,CAAC;AAM5D,MAAM,WAAW,mBAAmB;IAClC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAiBD,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ3E;AAED,wBAAsB,cAAc,CAClC,WAAW,EAAE,MAAM,GAAG,MAAM,EAC5B,IAAI,GAAE,mBAAwB,GAC7B,OAAO,CAAC,cAAc,CAAC,CAoIzB;AAkGD,wBAAsB,cAAc,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAkC1E;AAED,8FAA8F;AAC9F,wBAAsB,mBAAmB,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/E;AAED,6FAA6F;AAC7F,wBAAgB,yBAAyB,IAAI,IAAI,CAIhD;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,cAAc,GAAG,OAAO,SAAS,EAAE,aAAa,CA0B7F"}
@@ -0,0 +1,343 @@
1
+ import { mkdir, writeFile, readdir, readFile, access } from "node:fs/promises";
2
+ import { join } from "pathe";
3
+ import { ArtifactStore } from "./artifact-store.mjs";
4
+ import { debug, envFlag } from "./debug.mjs";
5
+ import { assertUniqueRecipeBinding, computeIdentity } from "./identity.mjs";
6
+ import { FileLock, lockPathFor } from "./lock.mjs";
7
+ import { loopbackUrl, resolveArtifactsRoot, resolveBindHost } from "./paths.mjs";
8
+ import { getFreePort } from "./ports.mjs";
9
+ import { progress } from "./progress.mjs";
10
+ import { defaultReady } from "./ready.mjs";
11
+ import { createRunHelper } from "./run-helper.mjs";
12
+ import { TargetRegistry } from "./target-registry.mjs";
13
+ import { getRegisteredRecipe } from "./recipes.mjs";
14
+ import { isPidAlive, killPidTree } from "./process.mjs";
15
+ import { ofetch } from "ofetch";
16
+ import { constants } from "node:fs";
17
+ const liveStops = new Map();
18
+ const liveRunning = new Map();
19
+ const liveMeta = new Map();
20
+ function rememberLive(id, running, meta, stop) {
21
+ liveRunning.set(id, running);
22
+ liveMeta.set(id, meta);
23
+ if (stop) liveStops.set(id, stop);
24
+ }
25
+ export async function resolveRecipe(input) {
26
+ if (typeof input === "string") {
27
+ const r = getRegisteredRecipe(input);
28
+ if (!r) throw new Error(`[untestutils] unknown recipe id "${input}". Did you call defineRecipes()?`);
29
+ return r;
30
+ }
31
+ return input;
32
+ }
33
+ export async function ensurePrepared(recipeInput, opts = {}) {
34
+ const recipe = await resolveRecipe(recipeInput);
35
+ const artifactsRoot = opts.artifactsRoot ?? resolveArtifactsRoot(opts.cwd);
36
+ const root = recipe.root;
37
+ const { identity, hash, id } = await computeIdentity(recipe, artifactsRoot, root);
38
+ const share = envFlag("UNTESTUTILS_SHARE", true) ? recipe.share ?? "always" : "never";
39
+
40
+ if (share !== "never") {
41
+ assertUniqueRecipeBinding(id, identity);
42
+ }
43
+ if (envFlag("UNTESTUTILS_SHARE", true) === false && recipe.share !== "never") {}
44
+ const store = new ArtifactStore(artifactsRoot);
45
+ const registry = new TargetRegistry(artifactsRoot);
46
+
47
+ const live = liveRunning.get(id);
48
+ if (live) {
49
+ const bound = liveMeta.get(id);
50
+ return {
51
+ id,
52
+ identity: bound?.identity ?? identity,
53
+ hash: bound?.hash ?? hash,
54
+ outDir: bound?.outDir ?? store.buildDir(identity),
55
+ running: live,
56
+ recipe
57
+ };
58
+ }
59
+
60
+
61
+ if (share === "always" || share === "never") {
62
+ const reused = await tryReuseRegistry(registry, store, id, identity, hash, share);
63
+ if (reused) return {
64
+ ...reused,
65
+ recipe
66
+ };
67
+ }
68
+
69
+ const lockKey = share === "never" ? id : identity;
70
+ const lock = new FileLock(lockPathFor(artifactsRoot, lockKey), lockKey);
71
+ await lock.acquire();
72
+ try {
73
+
74
+ if (share === "always" || share === "never") {
75
+ const reused = await tryReuseRegistry(registry, store, id, identity, hash, share);
76
+ if (reused) return {
77
+ ...reused,
78
+ recipe
79
+ };
80
+ }
81
+ const outDir = await store.ensureDir(identity);
82
+ const run = createRunHelper({
83
+ cwd: root,
84
+ env: scrubEnv()
85
+ });
86
+ if (!await store.isWarm(identity, hash)) {
87
+ if (recipe.prepare) {
88
+ progress.prepareStart(id);
89
+ const started = Date.now();
90
+ try {
91
+ await recipe.prepare({
92
+ root,
93
+ outDir,
94
+ artifactsRoot,
95
+ env: scrubEnv(),
96
+ run
97
+ });
98
+ if (recipe.verifyArtifact) await recipe.verifyArtifact(outDir);
99
+ await store.commit(identity, hash);
100
+ progress.prepareDone(id, Date.now() - started);
101
+ } catch (err) {
102
+ progress.fail(id, err);
103
+ throw err;
104
+ }
105
+ } else {
106
+ await store.commit(identity, hash);
107
+ }
108
+ } else {
109
+ progress.prepareCache(id);
110
+ debug("prepare", `cache hit ${id}`);
111
+ }
112
+ if (share === "prepare-only") {
113
+ const running = {
114
+ kind: "dir",
115
+ dir: outDir
116
+ };
117
+ await registry.set({
118
+ id,
119
+ identity,
120
+ dir: outDir
121
+ });
122
+ rememberLive(id, running, {
123
+ identity,
124
+ hash,
125
+ outDir
126
+ });
127
+ return {
128
+ id,
129
+ identity,
130
+ hash,
131
+ outDir,
132
+ running,
133
+ recipe
134
+ };
135
+ }
136
+ const bindHost = resolveBindHost();
137
+ const port = await getFreePort(bindHost);
138
+ const startCtx = {
139
+ root,
140
+ outDir,
141
+ artifactsRoot,
142
+ port,
143
+ host: bindHost,
144
+ env: scrubEnv(),
145
+ run: createRunHelper({
146
+ cwd: root,
147
+ env: {
148
+ ...scrubEnv(),
149
+ PORT: String(port),
150
+ HOST: bindHost
151
+ }
152
+ })
153
+ };
154
+ progress.start(id, loopbackUrl(port, "/", bindHost));
155
+ try {
156
+ const running = await recipe.start(startCtx);
157
+ if (recipe.ready) await recipe.ready(running);
158
+ else await defaultReady(running);
159
+ rememberLive(id, running, {
160
+ identity,
161
+ hash,
162
+ outDir
163
+ }, running.stop);
164
+ await replaceRegistryEntry(registry, {
165
+ id,
166
+ identity,
167
+ url: "url" in running ? running.url : undefined,
168
+ dir: "dir" in running ? running.dir : outDir,
169
+ pid: running.pid
170
+ });
171
+ await writeEnvSnapshot(outDir, {
172
+ id,
173
+ identity,
174
+ port,
175
+ hash
176
+ });
177
+ return {
178
+ id,
179
+ identity,
180
+ hash,
181
+ outDir,
182
+ running,
183
+ recipe
184
+ };
185
+ } catch (err) {
186
+ progress.fail(id, err);
187
+ throw err;
188
+ }
189
+ } finally {
190
+ await lock.release();
191
+ }
192
+ }
193
+
194
+ async function replaceRegistryEntry(registry, entry) {
195
+ const prev = await registry.set(entry);
196
+ if (prev?.pid && prev.pid !== entry.pid && isPidAlive(prev.pid)) {
197
+ debug("teardown", `replacing ${entry.id} — kill old pid ${prev.pid}`);
198
+ await killPidTree(prev.pid);
199
+ }
200
+ }
201
+ function scrubEnv() {
202
+ const env = { ...process.env };
203
+ for (const k of [
204
+ "VITEST",
205
+ "VITEST_WORKER_ID",
206
+ "TEST",
207
+ "JEST_WORKER_ID",
208
+ "JEST"
209
+ ]) delete env[k];
210
+ return env;
211
+ }
212
+ async function tryReuseRegistry(registry, store, id, identity, hash, share) {
213
+ const existing = await registry.get(id);
214
+ if (!existing?.url) return undefined;
215
+ const warmOk = share === "never" ? true : await store.isWarm(identity, hash);
216
+ if (!warmOk) return undefined;
217
+
218
+ if (existing.pid !== null && existing.pid !== undefined && !isPidAlive(existing.pid)) {
219
+ debug("registry", `stale pid ${existing.pid} for ${id}`);
220
+ return undefined;
221
+ }
222
+ if (!await isUrlAlive(existing.url)) return undefined;
223
+ const outDir = existing.dir ?? store.buildDir(identity);
224
+ const running = {
225
+ kind: "url+dir",
226
+ url: existing.url,
227
+ dir: outDir,
228
+ pid: existing.pid
229
+ };
230
+ progress.prepareCache(id);
231
+ progress.start(id, existing.url);
232
+
233
+ const stop = existing.pid !== null && existing.pid !== undefined ? async () => {
234
+ await killPidTree(existing.pid);
235
+ } : undefined;
236
+ rememberLive(id, running, {
237
+ identity,
238
+ hash,
239
+ outDir
240
+ }, stop);
241
+
242
+ registry.applyEnv({
243
+ ...existing,
244
+ url: existing.url
245
+ });
246
+ return {
247
+ id,
248
+ identity,
249
+ hash,
250
+ outDir,
251
+ running
252
+ };
253
+ }
254
+ async function isUrlAlive(url) {
255
+ try {
256
+ const ctrl = new AbortController();
257
+ const t = setTimeout(() => ctrl.abort(), 1500);
258
+ const res = await fetch(url, { signal: ctrl.signal });
259
+ clearTimeout(t);
260
+ return res.status < 500;
261
+ } catch {
262
+
263
+ return false;
264
+ }
265
+ }
266
+ async function writeEnvSnapshot(outDir, info) {
267
+ try {
268
+ await mkdir(outDir, { recursive: true });
269
+ const env = {};
270
+ for (const [k, v] of Object.entries(scrubEnv())) {
271
+ if (v !== undefined && !/secret|token|password|key/i.test(k)) env[k] = v;
272
+ }
273
+ await writeFile(join(outDir, "env.snapshot.json"), JSON.stringify({
274
+ ...info,
275
+ node: process.version,
276
+ env
277
+ }, null, 2), "utf8");
278
+ } catch {}
279
+ }
280
+ export async function stopAllTargets(artifactsRoot) {
281
+ const stops = [...liveStops.entries()];
282
+ liveStops.clear();
283
+ liveRunning.clear();
284
+ liveMeta.clear();
285
+ await Promise.all(stops.map(async ([id, stop]) => {
286
+ try {
287
+ await stop();
288
+ debug("teardown", `stopped ${id}`);
289
+ } catch (e) {
290
+ debug("teardown", `failed ${id}`, e);
291
+ }
292
+ }));
293
+
294
+
295
+ const root = artifactsRoot ?? resolveArtifactsRoot();
296
+ const registry = new TargetRegistry(root);
297
+ const map = await registry.drain();
298
+ const pids = Object.values(map).map((e) => e.pid).filter((p) => typeof p === "number" && p > 1);
299
+ await Promise.all(pids.map(async (pid) => {
300
+ try {
301
+ await killPidTree(pid);
302
+ debug("teardown", `killed registry pid ${pid}`);
303
+ } catch (e) {
304
+ debug("teardown", `failed registry pid ${pid}`, e);
305
+ }
306
+ }));
307
+ }
308
+
309
+ export async function reclaimStaleTargets(artifactsRoot) {
310
+ await stopAllTargets(artifactsRoot);
311
+ }
312
+
313
+ export function detachLiveTargetsForTests() {
314
+ liveStops.clear();
315
+ liveRunning.clear();
316
+ liveMeta.clear();
317
+ }
318
+ export function createHarnessHandle(prepared) {
319
+ const url = "url" in prepared.running ? prepared.running.url : undefined;
320
+ const dir = "dir" in prepared.running ? prepared.running.dir : prepared.outDir;
321
+ return {
322
+ id: prepared.id,
323
+ url,
324
+ dir,
325
+ $fetch: ((request, opts) => {
326
+ if (!url) throw new Error(`[untestutils] recipe "${prepared.id}" has no url for $fetch`);
327
+ const target = request.startsWith("http") ? request : new URL(request, url).toString();
328
+ return ofetch(target, opts);
329
+ }),
330
+ files: {
331
+ read: async (rel) => readFile(join(dir, rel), "utf8"),
332
+ exists: async (rel) => {
333
+ try {
334
+ await access(join(dir, rel), constants.F_OK);
335
+ return true;
336
+ } catch {
337
+ return false;
338
+ }
339
+ },
340
+ list: async (rel = ".") => readdir(join(dir, rel))
341
+ }
342
+ };
343
+ }
@@ -0,0 +1,20 @@
1
+ /** Default IPv4 loopback — prefer this over `localhost` (::1 trap). */
2
+ export declare const LOOPBACK_HOST = "127.0.0.1";
3
+ /**
4
+ * Host used to bind local servers and allocate free ports.
5
+ * Override with `UNTESTUTILS_BIND_HOST` (e.g. `0.0.0.0` to listen on all interfaces;
6
+ * ready probes still hit `127.0.0.1` via {@link resolveProbeHost}).
7
+ */
8
+ export declare function resolveBindHost(): string;
9
+ /** Host used in ready-check / harness URLs — never a wildcard bind address. */
10
+ export declare function resolveProbeHost(bindHost?: string): string;
11
+ export declare function loopbackUrl(port: number, path?: string, host?: string): string;
12
+ export declare function normalizeBaseUrl(url: string): string;
13
+ export declare function resolveArtifactsRoot(cwd?: string): string;
14
+ export declare function findRepoRoot(start?: string): string;
15
+ export declare function assertNotInsideWorkspacePackage(artifactsRoot: string, workspaceRoots: string[]): void;
16
+ /** Refuse writing builds into a directory that is a workspace package (has package.json + is under workspace). */
17
+ export declare function refuseArtifactsInsidePackage(outDir: string): void;
18
+ export declare function isWorkspacePackageDir(dir: string): boolean;
19
+ export declare function toFilePath(urlOrPath: string | URL): string;
20
+ export declare function expandHome(path: string): string;
@@ -0,0 +1,21 @@
1
+ /** Default IPv4 loopback — prefer this over `localhost` (::1 trap). */
2
+ export declare const LOOPBACK_HOST = "127.0.0.1";
3
+ /**
4
+ * Host used to bind local servers and allocate free ports.
5
+ * Override with `UNTESTUTILS_BIND_HOST` (e.g. `0.0.0.0` to listen on all interfaces;
6
+ * ready probes still hit `127.0.0.1` via {@link resolveProbeHost}).
7
+ */
8
+ export declare function resolveBindHost(): string;
9
+ /** Host used in ready-check / harness URLs — never a wildcard bind address. */
10
+ export declare function resolveProbeHost(bindHost?: string): string;
11
+ export declare function loopbackUrl(port: number, path?: string, host?: string): string;
12
+ export declare function normalizeBaseUrl(url: string): string;
13
+ export declare function resolveArtifactsRoot(cwd?: string): string;
14
+ export declare function findRepoRoot(start?: string): string;
15
+ export declare function assertNotInsideWorkspacePackage(artifactsRoot: string, workspaceRoots: string[]): void;
16
+ /** Refuse writing builds into a directory that is a workspace package (has package.json + is under workspace). */
17
+ export declare function refuseArtifactsInsidePackage(outDir: string): void;
18
+ export declare function isWorkspacePackageDir(dir: string): boolean;
19
+ export declare function toFilePath(urlOrPath: string | URL): string;
20
+ export declare function expandHome(path: string): string;
21
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAKA,uEAAuE;AACvE,eAAO,MAAM,aAAa,cAAc,CAAC;AAEzC;;;;GAIG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAKxC;AAED,+EAA+E;AAC/E,wBAAgB,gBAAgB,CAAC,QAAQ,GAAE,MAA0B,GAAG,MAAM,CAI7E;AAED,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,MAAY,EAClB,IAAI,GAAE,MAA0B,GAC/B,MAAM,CAIR;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAapD;AAED,wBAAgB,oBAAoB,CAAC,GAAG,GAAE,MAAsB,GAAG,MAAM,CAMxE;AAED,wBAAgB,YAAY,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,CAUlE;AAED,wBAAgB,+BAA+B,CAC7C,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,EAAE,GACvB,IAAI,CAsBN;AAED,kHAAkH;AAClH,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAkCjE;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE1D;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,GAAG,GAAG,MAAM,CAM1D;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG/C"}