@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.
- package/LICENSE +21 -0
- package/dist/artifact-store.d.mts +19 -0
- package/dist/artifact-store.d.ts +20 -0
- package/dist/artifact-store.d.ts.map +1 -0
- package/dist/artifact-store.mjs +52 -0
- package/dist/debug.d.mts +5 -0
- package/dist/debug.d.ts +6 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.mjs +22 -0
- package/dist/drivers/cli-framework.d.mts +57 -0
- package/dist/drivers/cli-framework.d.ts +64 -0
- package/dist/drivers/cli-framework.d.ts.map +1 -0
- package/dist/drivers/cli-framework.mjs +138 -0
- package/dist/drivers/command.d.mts +15 -0
- package/dist/drivers/command.d.ts +16 -0
- package/dist/drivers/command.d.ts.map +1 -0
- package/dist/drivers/command.mjs +70 -0
- package/dist/drivers/define-driver.d.mts +4 -0
- package/dist/drivers/define-driver.d.ts +5 -0
- package/dist/drivers/define-driver.d.ts.map +1 -0
- package/dist/drivers/define-driver.mjs +3 -0
- package/dist/drivers/host.d.mts +18 -0
- package/dist/drivers/host.d.ts +19 -0
- package/dist/drivers/host.d.ts.map +1 -0
- package/dist/drivers/host.mjs +31 -0
- package/dist/drivers/index.d.mts +12 -0
- package/dist/drivers/index.d.ts +13 -0
- package/dist/drivers/index.d.ts.map +1 -0
- package/dist/drivers/index.mjs +6 -0
- package/dist/drivers/node-entry.d.mts +13 -0
- package/dist/drivers/node-entry.d.ts +14 -0
- package/dist/drivers/node-entry.d.ts.map +1 -0
- package/dist/drivers/node-entry.mjs +55 -0
- package/dist/drivers/static-dir.d.mts +9 -0
- package/dist/drivers/static-dir.d.ts +10 -0
- package/dist/drivers/static-dir.d.ts.map +1 -0
- package/dist/drivers/static-dir.mjs +90 -0
- package/dist/harness.d.mts +4 -0
- package/dist/harness.d.ts +5 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/harness.mjs +18 -0
- package/dist/hash.d.mts +6 -0
- package/dist/hash.d.ts +7 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.mjs +76 -0
- package/dist/identity.d.mts +9 -0
- package/dist/identity.d.ts +10 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.mjs +44 -0
- package/dist/index.d.mts +29 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +22 -0
- package/dist/lock.d.mts +26 -0
- package/dist/lock.d.ts +27 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.mjs +145 -0
- package/dist/orchestrator.d.mts +19 -0
- package/dist/orchestrator.d.ts +22 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.mjs +343 -0
- package/dist/paths.d.mts +20 -0
- package/dist/paths.d.ts +21 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.mjs +124 -0
- package/dist/ports.d.mts +2 -0
- package/dist/ports.d.ts +8 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.mjs +38 -0
- package/dist/process.d.mts +31 -0
- package/dist/process.d.ts +36 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.mjs +191 -0
- package/dist/progress.d.mts +45 -0
- package/dist/progress.d.ts +66 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.mjs +205 -0
- package/dist/ready.d.mts +11 -0
- package/dist/ready.d.ts +12 -0
- package/dist/ready.d.ts.map +1 -0
- package/dist/ready.mjs +142 -0
- package/dist/recipes.d.mts +23 -0
- package/dist/recipes.d.ts +24 -0
- package/dist/recipes.d.ts.map +1 -0
- package/dist/recipes.mjs +94 -0
- package/dist/run-helper.d.mts +6 -0
- package/dist/run-helper.d.ts +7 -0
- package/dist/run-helper.d.ts.map +1 -0
- package/dist/run-helper.mjs +79 -0
- package/dist/target-registry.d.mts +27 -0
- package/dist/target-registry.d.ts +28 -0
- package/dist/target-registry.d.ts.map +1 -0
- package/dist/target-registry.mjs +103 -0
- package/dist/types.d.mts +92 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.mjs +1 -0
- 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
|
+
}
|
package/dist/paths.d.mts
ADDED
|
@@ -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;
|
package/dist/paths.d.ts
ADDED
|
@@ -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"}
|