mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-30 19:07:33 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ffddf5f5d | |||
| f36852c763 | |||
| ce99ff6d7c | |||
| b81404c7a1 | |||
| 94792cf26b | |||
| 5b7fa44068 | |||
| 08936cd203 | |||
| bda53e8c1d | |||
| 6504267ac9 | |||
| 7e6ca26ad7 | |||
| 7b2d9154f9 | |||
| d1aff0d8ec | |||
| 6de4b75fba | |||
| 35de5d2d23 |
@@ -45,7 +45,9 @@ yarn build --stop
|
||||
|
||||
Use `yarn build --modern --background` for production bundle-size or browser performance comparisons that only need modern browser output. It runs the normal metadata and static preparation, minifies and compresses the modern `frontend_latest` bundle and shared static assets, and generates modern-only entry pages and service workers. It deliberately skips the legacy bundle and its service worker.
|
||||
|
||||
Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`; that raw script does not parse arguments and always starts the full foreground build. Use `yarn build` for managed builds. It prevents another foreground or background build from starting while one is using the shared generated files under `build/` and `hass_frontend/`.
|
||||
Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`; that raw script does not parse arguments and always starts the full foreground build. Use `yarn build` for managed builds. App builds and development servers keep exclusive ownership of `hass_frontend/` for their lifetime.
|
||||
|
||||
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
|
||||
|
||||
## Unit And Utility Tests
|
||||
|
||||
@@ -78,7 +80,7 @@ The custom development wrappers use `/__ha_dev_status` to identify and manage th
|
||||
|
||||
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs suites sequentially when managed servers are unavailable to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
|
||||
|
||||
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
|
||||
|
||||
|
||||
@@ -25,24 +25,26 @@ import {
|
||||
runCli,
|
||||
spawnDetachedToLog,
|
||||
spawnForeground,
|
||||
terminateDetachedProcess,
|
||||
terminateProcess,
|
||||
waitFor,
|
||||
writeProcessRecord,
|
||||
} from "./managed-process.mjs";
|
||||
import {
|
||||
buildCacheDir,
|
||||
describeOutputOwner,
|
||||
workflowLockEnv,
|
||||
workflowLockFile,
|
||||
} from "./output-lock.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
|
||||
const stateDir = path.join(repoRoot, "node_modules", ".cache", "ha-build");
|
||||
const stateDir = path.join(buildCacheDir, "ha-build");
|
||||
const logFile = path.join(stateDir, "build.log");
|
||||
const lockFile = path.join(
|
||||
repoRoot,
|
||||
"node_modules",
|
||||
".cache",
|
||||
"ha-generated-output.lock"
|
||||
);
|
||||
const lockFile = workflowLockFile;
|
||||
|
||||
const usage = () => {
|
||||
process.stderr.write(
|
||||
@@ -79,6 +81,21 @@ const hints = () =>
|
||||
" Status: yarn build --status\n" +
|
||||
" Logs: yarn build --logs\n";
|
||||
|
||||
const devCommand = (suite) => {
|
||||
switch (suite) {
|
||||
case "app-serve":
|
||||
return "dev:serve";
|
||||
case "demo":
|
||||
return "dev:demo";
|
||||
case "gallery":
|
||||
return "dev:gallery";
|
||||
case "e2e-app":
|
||||
return "test:e2e:app:dev";
|
||||
default:
|
||||
return "dev";
|
||||
}
|
||||
};
|
||||
|
||||
const readBuild = () => readProcessRecord(lockFile);
|
||||
|
||||
const releaseBuild = (token) => releaseProcessRecord(lockFile, token);
|
||||
@@ -115,8 +132,15 @@ const updateBuild = (token, child, processGroup) => {
|
||||
const taskFor = (modern) => (modern ? "build-app-modern" : "build-app");
|
||||
|
||||
const reportExisting = (existing) => {
|
||||
if (existing?.kind === "output") {
|
||||
process.stdout.write(
|
||||
`${describeOutputOwner(existing)} already owns the build and development workflow` +
|
||||
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (existing?.kind === "dev") {
|
||||
const command = existing.suite === "app-serve" ? "dev:serve" : "dev";
|
||||
const command = devCommand(existing.suite);
|
||||
process.stdout.write(
|
||||
`Dev server (${existing.suite}) already running` +
|
||||
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` +
|
||||
@@ -143,6 +167,7 @@ const runForeground = async (modern) => {
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateBuild(lock.token, child, true),
|
||||
});
|
||||
@@ -157,11 +182,13 @@ const runBackground = async (modern) => {
|
||||
reportExisting(lock.existing);
|
||||
return 1;
|
||||
}
|
||||
let child;
|
||||
try {
|
||||
const child = await spawnDetachedToLog({
|
||||
child = await spawnDetachedToLog({
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
updateBuild(lock.token, child, true);
|
||||
@@ -171,6 +198,9 @@ const runBackground = async (modern) => {
|
||||
);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
if (child) {
|
||||
await terminateDetachedProcess(child);
|
||||
}
|
||||
releaseBuild(lock.token);
|
||||
throw err;
|
||||
}
|
||||
@@ -245,11 +275,7 @@ const main = async () => {
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (
|
||||
args.modes.length > 1 ||
|
||||
(args.follow && args.mode !== "logs") ||
|
||||
(args.modern && !["foreground", "background"].includes(args.mode))
|
||||
) {
|
||||
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
|
||||
process.stderr.write("Invalid combination of build arguments.\n");
|
||||
usage();
|
||||
return 1;
|
||||
|
||||
+162
-243
@@ -14,13 +14,10 @@
|
||||
//
|
||||
// health demo, gallery, e2e-app: a fixed port plus the /__ha_dev_status
|
||||
// endpoint each dev server exposes (see runDevServer in
|
||||
// build-scripts/gulp/rspack.js). The port is the source of truth and
|
||||
// the pid is found from it; no state file.
|
||||
// process app (yarn dev) and app-serve (yarn dev:serve): the app watcher has
|
||||
// no health endpoint, and plain yarn dev has no port at all, so these
|
||||
// track a pidfile and treat the first "Build done" log line as ready.
|
||||
// build-scripts/gulp/rspack.js).
|
||||
// process app (yarn dev) and app-serve (yarn dev:serve): plain yarn dev has
|
||||
// no port, so these treat the first "Build done" log line as ready.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -32,15 +29,20 @@ import {
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
removeProcessRecord,
|
||||
runCli,
|
||||
sleep,
|
||||
spawnDetachedToLog,
|
||||
spawnForeground,
|
||||
terminateDetachedProcess,
|
||||
terminateProcess,
|
||||
waitFor,
|
||||
writeProcessRecord,
|
||||
} from "./managed-process.mjs";
|
||||
import {
|
||||
buildCacheDir,
|
||||
describeOutputOwner,
|
||||
workflowLockEnv,
|
||||
workflowLockFile,
|
||||
} from "./output-lock.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
@@ -52,13 +54,7 @@ const developAndServeScript = path.join(
|
||||
"script",
|
||||
"develop_and_serve"
|
||||
);
|
||||
const logDir = path.join(repoRoot, "node_modules", ".cache", "ha-dev-server");
|
||||
const outputLockFile = path.join(
|
||||
repoRoot,
|
||||
"node_modules",
|
||||
".cache",
|
||||
"ha-generated-output.lock"
|
||||
);
|
||||
const logDir = path.join(buildCacheDir, "ha-dev-server");
|
||||
|
||||
// Each suite names its yarn alias (for hints), a liveness model, and how to
|
||||
// spawn it. health suites carry a fixed port; process suites carry the log line
|
||||
@@ -98,7 +94,6 @@ const SUITES = new Map([
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
||||
processKey: "app",
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -109,7 +104,6 @@ const SUITES = new Map([
|
||||
acceptsArgs: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
processKey: "app",
|
||||
},
|
||||
],
|
||||
]);
|
||||
@@ -185,27 +179,7 @@ const parseArgs = (argv) => {
|
||||
};
|
||||
|
||||
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
|
||||
const pidFileFor = (suite) =>
|
||||
path.join(logDir, `${SUITES.get(suite).processKey ?? suite}.pid`);
|
||||
const removePidFileIf = (suite, matches) => {
|
||||
const existing = readProcessRecord(pidFileFor(suite));
|
||||
if (!existing) {
|
||||
removeProcessRecord(pidFileFor(suite));
|
||||
return true;
|
||||
}
|
||||
if (matches(existing)) {
|
||||
releaseProcessRecord(outputLockFile, existing.token, () => {
|
||||
if (readProcessRecord(pidFileFor(suite))?.token === existing.token) {
|
||||
removeProcessRecord(pidFileFor(suite));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const acquireProcessSuite = (suite) => {
|
||||
const pidFile = pidFileFor(suite);
|
||||
const acquireSuite = (suite) => {
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const record = {
|
||||
pid: process.pid,
|
||||
@@ -215,48 +189,37 @@ const acquireProcessSuite = (suite) => {
|
||||
starting: true,
|
||||
token,
|
||||
};
|
||||
const result = acquireProcessRecord(outputLockFile, record);
|
||||
if (!result.acquired) {
|
||||
return { existing: result.existing };
|
||||
}
|
||||
try {
|
||||
writeProcessRecord(pidFile, record);
|
||||
} catch (err) {
|
||||
releaseProcessRecord(outputLockFile, token);
|
||||
throw err;
|
||||
}
|
||||
return { token };
|
||||
const result = acquireProcessRecord(workflowLockFile, record);
|
||||
return result.acquired ? { token } : { existing: result.existing };
|
||||
};
|
||||
|
||||
const updateProcessSuite = (suite, token, child) => {
|
||||
const existing = readPidFile(suite);
|
||||
const updateSuite = (suite, token, child, port) => {
|
||||
const existing = readProcessRecord(workflowLockFile);
|
||||
if (existing?.token !== token) {
|
||||
throw Error(
|
||||
`Dev server (${suite}) process ownership was lost during startup.`
|
||||
);
|
||||
throw Error(`Dev server (${suite}) ownership was lost during startup.`);
|
||||
}
|
||||
const record = {
|
||||
writeProcessRecord(workflowLockFile, {
|
||||
...existing,
|
||||
pid: child.pid,
|
||||
startTime: processStartTime(child.pid),
|
||||
processGroup: true,
|
||||
starting: false,
|
||||
};
|
||||
writePidFile(suite, record);
|
||||
const outputOwner = readProcessRecord(outputLockFile);
|
||||
if (outputOwner?.token !== token) {
|
||||
throw Error(
|
||||
`Dev server (${suite}) output ownership was lost during startup.`
|
||||
);
|
||||
}
|
||||
writeProcessRecord(outputLockFile, record);
|
||||
port,
|
||||
});
|
||||
};
|
||||
|
||||
const releaseProcessSuite = (suite, token) => {
|
||||
releaseProcessRecord(outputLockFile, token, () => {
|
||||
if (readPidFile(suite)?.token === token) {
|
||||
removeProcessRecord(pidFileFor(suite));
|
||||
}
|
||||
});
|
||||
const releaseSuite = (token) => releaseProcessRecord(workflowLockFile, token);
|
||||
|
||||
const readSuite = (suite) => {
|
||||
const existing = readProcessRecord(workflowLockFile);
|
||||
if (existing?.kind !== "dev" || existing.suite !== suite) {
|
||||
return undefined;
|
||||
}
|
||||
if (isProcessRecordAlive(existing)) {
|
||||
return existing;
|
||||
}
|
||||
releaseSuite(existing.token);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const hints = (suite) => {
|
||||
@@ -269,6 +232,13 @@ const hints = (suite) => {
|
||||
};
|
||||
|
||||
const reportProcessConflict = (suite, existing) => {
|
||||
if (existing?.kind === "output") {
|
||||
process.stdout.write(
|
||||
`${describeOutputOwner(existing)} already owns the app output` +
|
||||
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (existing?.kind === "build") {
|
||||
process.stdout.write(
|
||||
`Frontend build already running${existing.pid ? ` (pid ${existing.pid})` : ""}. ` +
|
||||
@@ -284,6 +254,22 @@ const reportProcessConflict = (suite, existing) => {
|
||||
);
|
||||
};
|
||||
|
||||
const acquireSuiteForStart = (suite) => {
|
||||
const lock = acquireSuite(suite);
|
||||
if (lock.token) {
|
||||
return lock;
|
||||
}
|
||||
reportProcessConflict(suite, lock.existing);
|
||||
return {
|
||||
code:
|
||||
lock.existing?.kind === "dev" &&
|
||||
lock.existing.suite === suite &&
|
||||
!lock.existing.starting
|
||||
? 0
|
||||
: 1,
|
||||
};
|
||||
};
|
||||
|
||||
// --- shared spawning and lifecycle ------------------------------------------
|
||||
|
||||
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
|
||||
@@ -394,163 +380,116 @@ const probe = async (port, timeoutMs = 1000) => {
|
||||
return probeHost(0, false);
|
||||
};
|
||||
|
||||
// Find the pid listening on a port via the first available tool (no state file).
|
||||
const pidFromPort = (port) => {
|
||||
const attempts = [
|
||||
[
|
||||
"lsof",
|
||||
["-ti", `tcp:${port}`, "-sTCP:LISTEN"],
|
||||
(out) => out.trim().split("\n")[0],
|
||||
],
|
||||
[
|
||||
"ss",
|
||||
["-ltnpH", `sport = :${port}`],
|
||||
(out) => out.match(/pid=(\d+)/)?.[1],
|
||||
],
|
||||
["fuser", [`${port}/tcp`], (out) => out.trim().split(/\s+/)[0]],
|
||||
];
|
||||
for (const [cmd, cmdArgs, extract] of attempts) {
|
||||
const isHttpServing = async (port, timeoutMs = 1000) => {
|
||||
const probeHost = async (index) => {
|
||||
const host = PROBE_HOSTS[index];
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const out = execFileSync(cmd, cmdArgs, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
const response = await fetch(`http://${host}:${port}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const pid = Number(extract(out));
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
return pid;
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Try the next tool.
|
||||
// Try the next address.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return probeHost(index + 1);
|
||||
};
|
||||
return probeHost(0);
|
||||
};
|
||||
|
||||
const runForegroundHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const status = await probe(port);
|
||||
if (status.state === "ours" && status.suite === suite) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) is already running at http://localhost:${port}\n`
|
||||
);
|
||||
return 0;
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
return lock.code;
|
||||
}
|
||||
const status = await probe(port);
|
||||
if (status.state === "ours") {
|
||||
releaseSuite(lock.token);
|
||||
process.stderr.write(
|
||||
`Port ${port} is serving the ${status.suite ?? "unknown"} dev server; not ${suite}.\n`
|
||||
`Port ${port} is already serving the ${status.suite ?? "unknown"} dev server.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (status.state === "foreign") {
|
||||
releaseSuite(lock.token);
|
||||
process.stderr.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return spawnForeground({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: cfg.spawn.args,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
try {
|
||||
return await spawnForeground({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: cfg.spawn.args,
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateSuite(suite, lock.token, child, port),
|
||||
});
|
||||
} finally {
|
||||
releaseSuite(lock.token);
|
||||
}
|
||||
};
|
||||
|
||||
const runBackgroundHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const preflight = await probe(port);
|
||||
if (preflight.state === "ours" && preflight.suite === suite) {
|
||||
const pid = pidFromPort(port);
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running at http://localhost:${port}` +
|
||||
`${pid ? ` (pid ${pid})` : ""}\n${hints(suite)}`
|
||||
);
|
||||
return 0;
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
return lock.code;
|
||||
}
|
||||
const preflight = await probe(port);
|
||||
if (preflight.state === "ours") {
|
||||
releaseSuite(lock.token);
|
||||
process.stderr.write(
|
||||
`Port ${port} is serving the ${preflight.suite ?? "unknown"} dev server; not ${suite}.\n`
|
||||
`Port ${port} is already serving the ${preflight.suite ?? "unknown"} dev server.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (preflight.state === "foreign") {
|
||||
releaseSuite(lock.token);
|
||||
process.stderr.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const logFile = logFileFor(suite);
|
||||
const child = await spawnDetachedToLog({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: cfg.spawn.args,
|
||||
cwd: repoRoot,
|
||||
logFile,
|
||||
});
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: async () => {
|
||||
const status = await probe(port, 1000);
|
||||
return status.state === "ours" && status.suite === suite;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const runStatusHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const status = await probe(port);
|
||||
if (status.state === "ours" && status.suite === suite) {
|
||||
const pid = pidFromPort(port);
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) running at http://localhost:${port}` +
|
||||
`${pid ? ` (pid ${pid})` : ""}\n`
|
||||
);
|
||||
} else if (status.state === "ours") {
|
||||
process.stdout.write(
|
||||
`Port ${port} is serving a different Home Assistant frontend dev server (suite ${status.suite ?? "unknown"}); not ${suite}.\n`
|
||||
);
|
||||
} else if (status.state === "foreign") {
|
||||
process.stdout.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
let child;
|
||||
try {
|
||||
const logFile = logFileFor(suite);
|
||||
child = await spawnDetachedToLog({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: cfg.spawn.args,
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
updateSuite(suite, lock.token, child, port);
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: async () => {
|
||||
const status = await probe(port, 1000);
|
||||
return status.state === "ours" && status.suite === suite;
|
||||
},
|
||||
onExit: () => releaseSuite(lock.token),
|
||||
});
|
||||
} catch (err) {
|
||||
if (child) {
|
||||
await terminateDetachedProcess(child);
|
||||
}
|
||||
releaseSuite(lock.token);
|
||||
throw err;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runStopHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const status = await probe(port);
|
||||
if (!(status.state === "ours" && status.suite === suite)) {
|
||||
// Idempotent: stopping something that is not running is a success.
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
return 0;
|
||||
}
|
||||
const pid = pidFromPort(port);
|
||||
if (!pid) {
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) is running but its pid could not be found ` +
|
||||
`(no lsof/ss/fuser?). Stop it manually.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return terminate(
|
||||
suite,
|
||||
pid,
|
||||
async () => (await probe(port, 800)).state === "free"
|
||||
);
|
||||
};
|
||||
|
||||
// --- process liveness (pidfile + log-readiness) -----------------------------
|
||||
|
||||
const readPidFile = (suite) => {
|
||||
return readProcessRecord(pidFileFor(suite));
|
||||
};
|
||||
|
||||
const writePidFile = (suite, data) => {
|
||||
writeProcessRecord(pidFileFor(suite), data);
|
||||
};
|
||||
|
||||
const logIsReady = (logFile, readyLog) => {
|
||||
@@ -582,101 +521,79 @@ const spawnArgs = (cfg, passthrough) => [
|
||||
];
|
||||
|
||||
const runForegroundProcess = async (suite, cfg, passthrough) => {
|
||||
const lock = acquireProcessSuite(suite);
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
reportProcessConflict(suite, lock.existing);
|
||||
return 0;
|
||||
return lock.code;
|
||||
}
|
||||
try {
|
||||
return await spawnForeground({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: spawnArgs(cfg, passthrough),
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateProcessSuite(suite, lock.token, child),
|
||||
onSpawn: (child) => updateSuite(suite, lock.token, child),
|
||||
});
|
||||
} finally {
|
||||
releaseProcessSuite(suite, lock.token);
|
||||
releaseSuite(lock.token);
|
||||
}
|
||||
};
|
||||
|
||||
const runBackgroundProcess = async (suite, cfg, passthrough) => {
|
||||
const lock = acquireProcessSuite(suite);
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
reportProcessConflict(suite, lock.existing);
|
||||
return 0;
|
||||
return lock.code;
|
||||
}
|
||||
|
||||
let child;
|
||||
try {
|
||||
const logFile = logFileFor(suite);
|
||||
const child = await spawnDetachedToLog({
|
||||
child = await spawnDetachedToLog({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: spawnArgs(cfg, passthrough),
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
|
||||
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
|
||||
updateProcessSuite(suite, lock.token, child);
|
||||
writePidFile(suite, { ...readPidFile(suite), port });
|
||||
updateSuite(suite, lock.token, child, port);
|
||||
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: () => logIsReady(logFile, cfg.readyLog),
|
||||
onExit: () => releaseProcessSuite(suite, lock.token),
|
||||
isReady: async () =>
|
||||
logIsReady(logFile, cfg.readyLog) &&
|
||||
(!cfg.acceptsArgs || (await isHttpServing(port))),
|
||||
onExit: () => releaseSuite(lock.token),
|
||||
});
|
||||
} catch (err) {
|
||||
releaseProcessSuite(suite, lock.token);
|
||||
if (child) {
|
||||
await terminateDetachedProcess(child);
|
||||
}
|
||||
releaseSuite(lock.token);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const runStatusProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isProcessRecordAlive(existing)) {
|
||||
const runStatusSuite = async (suite, cfg) => {
|
||||
const existing = readSuite(suite);
|
||||
if (existing) {
|
||||
process.stdout.write(
|
||||
`Dev server (${existing.suite ?? suite}) running${urlSuffix(existing.port)} ` +
|
||||
`Dev server (${existing.suite ?? suite}) running${urlSuffix(existing.port ?? cfg.port)} ` +
|
||||
`(pid ${existing.pid})\n`
|
||||
);
|
||||
} else {
|
||||
if (existing) {
|
||||
removePidFileIf(
|
||||
suite,
|
||||
(current) =>
|
||||
current.token === existing.token && !isProcessRecordAlive(current)
|
||||
);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runStopProcess = async (suite) => {
|
||||
let existing = readPidFile(suite);
|
||||
if (existing?.starting) {
|
||||
const token = existing.token;
|
||||
await waitFor(
|
||||
() => {
|
||||
const current = readPidFile(suite);
|
||||
return !current?.starting || current.token !== token;
|
||||
},
|
||||
100,
|
||||
5000
|
||||
);
|
||||
existing = readPidFile(suite);
|
||||
}
|
||||
if (!existing || !isProcessRecordAlive(existing)) {
|
||||
// Idempotent: stopping something that is not running is a success.
|
||||
if (existing) {
|
||||
removePidFileIf(
|
||||
suite,
|
||||
(current) =>
|
||||
current.token === existing.token && !isProcessRecordAlive(current)
|
||||
);
|
||||
}
|
||||
const runStopSuite = async (suite) => {
|
||||
const existing = readSuite(suite);
|
||||
if (!existing) {
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
return 0;
|
||||
}
|
||||
@@ -686,14 +603,14 @@ const runStopProcess = async (suite) => {
|
||||
activeSuite,
|
||||
pid,
|
||||
() => !isProcessRecordAlive(existing),
|
||||
() => releaseProcessSuite(activeSuite, existing.token)
|
||||
() => releaseSuite(existing.token)
|
||||
);
|
||||
};
|
||||
|
||||
// --- shared -----------------------------------------------------------------
|
||||
|
||||
const runLogs = (suite, follow) => {
|
||||
const activeSuite = readPidFile(suite)?.suite ?? suite;
|
||||
const activeSuite = readSuite(suite)?.suite ?? suite;
|
||||
return outputLog(
|
||||
logFileFor(activeSuite),
|
||||
follow,
|
||||
@@ -739,21 +656,23 @@ const main = async () => {
|
||||
if (mode === "logs") {
|
||||
return runLogs(args.suite, args.follow);
|
||||
}
|
||||
if (mode === "status") {
|
||||
return runStatusSuite(args.suite, cfg);
|
||||
}
|
||||
if (mode === "stop") {
|
||||
return runStopSuite(args.suite);
|
||||
}
|
||||
const handlers =
|
||||
cfg.liveness === "health"
|
||||
? {
|
||||
foreground: () => runForegroundHealth(args.suite, cfg),
|
||||
background: () => runBackgroundHealth(args.suite, cfg),
|
||||
status: () => runStatusHealth(args.suite, cfg),
|
||||
stop: () => runStopHealth(args.suite, cfg),
|
||||
}
|
||||
: {
|
||||
foreground: () =>
|
||||
runForegroundProcess(args.suite, cfg, args.passthrough),
|
||||
background: () =>
|
||||
runBackgroundProcess(args.suite, cfg, args.passthrough),
|
||||
status: () => runStatusProcess(args.suite),
|
||||
stop: () => runStopProcess(args.suite),
|
||||
};
|
||||
return handlers[mode]();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import gulp from "gulp";
|
||||
import env from "../env.cjs";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./compress.js";
|
||||
import "./entry-html.js";
|
||||
@@ -17,6 +18,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-app"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-service-worker-app-dev",
|
||||
@@ -36,6 +38,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-app"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
@@ -57,6 +60,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-app-modern"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import gulp from "gulp";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
@@ -13,6 +14,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-demo"),
|
||||
"clean-demo",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -32,6 +34,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-demo"),
|
||||
"clean-demo",
|
||||
// Cast needs to be backwards compatible and older HA has no translations
|
||||
"translations-enable-merge-backend",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import gulp from "gulp";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
@@ -12,6 +13,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-e2e-test-app"),
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -31,6 +33,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-e2e-test-app"),
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import gulp from "gulp";
|
||||
import { load as loadYaml } from "js-yaml";
|
||||
import { marked } from "marked";
|
||||
import path from "path";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import paths from "../paths.cjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
@@ -164,6 +165,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-gallery"),
|
||||
"clean-gallery",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -195,6 +197,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-gallery"),
|
||||
"clean-gallery",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
|
||||
@@ -108,7 +108,7 @@ const runDevServer = async ({
|
||||
}
|
||||
};
|
||||
|
||||
const doneHandler = (done) => (err, stats) => {
|
||||
const doneHandler = () => (err, stats) => {
|
||||
if (err) {
|
||||
log.error(err.stack || err);
|
||||
if (err.details) {
|
||||
@@ -122,18 +122,26 @@ const doneHandler = (done) => (err, stats) => {
|
||||
}
|
||||
|
||||
log(`Build done @ ${new Date().toLocaleTimeString()}`);
|
||||
|
||||
if (done) {
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
const prodBuild = (conf) =>
|
||||
new Promise((resolve) => {
|
||||
new Promise((resolve, reject) => {
|
||||
rspack(
|
||||
conf,
|
||||
// Resolve promise when done. Because we pass a callback, rspack closes itself
|
||||
doneHandler(resolve)
|
||||
(err, stats) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (stats.hasErrors()) {
|
||||
reject(Error(stats.toString("errors-only")));
|
||||
} else {
|
||||
if (stats.hasWarnings()) {
|
||||
console.log(stats.toString("minimal"));
|
||||
}
|
||||
log(`Build done @ ${new Date().toLocaleTimeString()}`);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import { deleteAsync } from "del";
|
||||
import { glob } from "glob";
|
||||
import gulp from "gulp";
|
||||
|
||||
@@ -212,6 +212,7 @@ export const spawnForeground = ({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
processGroup = false,
|
||||
onSpawn,
|
||||
}) =>
|
||||
@@ -219,6 +220,7 @@ export const spawnForeground = ({
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: processGroup,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
let settled = false;
|
||||
@@ -265,7 +267,7 @@ export const spawnForeground = ({
|
||||
});
|
||||
});
|
||||
|
||||
export const spawnDetachedToLog = ({ cmd, args, cwd, logFile }) =>
|
||||
export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
||||
const fd = fs.openSync(logFile, "w");
|
||||
@@ -274,6 +276,7 @@ export const spawnDetachedToLog = ({ cmd, args, cwd, logFile }) =>
|
||||
child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: true,
|
||||
env,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
} finally {
|
||||
@@ -317,6 +320,13 @@ export const terminateProcess = async ({
|
||||
return await isStopped();
|
||||
};
|
||||
|
||||
export const terminateDetachedProcess = (child) =>
|
||||
terminateProcess({
|
||||
pid: child.pid,
|
||||
processGroup: true,
|
||||
isStopped: () => !isProcessAlive(child.pid),
|
||||
});
|
||||
|
||||
export const outputLog = (logFile, follow, missingMessage) => {
|
||||
if (!fs.existsSync(logFile)) {
|
||||
process.stdout.write(missingMessage);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
acquireProcessRecord,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
} from "./managed-process.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
export const buildCacheDir =
|
||||
process.env.HA_BUILD_CACHE_DIR ??
|
||||
path.join(repoRoot, "node_modules", ".cache");
|
||||
|
||||
const WORKFLOW_LOCK_TOKEN_ENV = "HA_WORKFLOW_LOCK_TOKEN";
|
||||
const signalCleanups = new Set();
|
||||
const cleanupSignals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
||||
|
||||
const handleSignal = (signal) => {
|
||||
for (const cleanup of signalCleanups) {
|
||||
cleanup();
|
||||
}
|
||||
for (const cleanupSignal of cleanupSignals) {
|
||||
process.off(cleanupSignal, handleSignal);
|
||||
}
|
||||
process.kill(process.pid, signal);
|
||||
};
|
||||
|
||||
const registerSignalCleanup = (cleanup) => {
|
||||
if (signalCleanups.size === 0) {
|
||||
for (const signal of cleanupSignals) {
|
||||
process.on(signal, handleSignal);
|
||||
}
|
||||
}
|
||||
signalCleanups.add(cleanup);
|
||||
};
|
||||
|
||||
const unregisterSignalCleanup = (cleanup) => {
|
||||
signalCleanups.delete(cleanup);
|
||||
if (signalCleanups.size === 0) {
|
||||
for (const signal of cleanupSignals) {
|
||||
process.off(signal, handleSignal);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const workflowLockFile = path.join(buildCacheDir, "ha-workflow.lock");
|
||||
|
||||
export const workflowLockEnv = (token) => ({
|
||||
...process.env,
|
||||
[WORKFLOW_LOCK_TOKEN_ENV]: token,
|
||||
});
|
||||
|
||||
export const describeOutputOwner = (owner) => {
|
||||
if (owner?.kind === "build") {
|
||||
return `frontend ${owner.modern ? "modern " : ""}build`;
|
||||
}
|
||||
if (owner?.kind === "dev") {
|
||||
return `dev server (${owner.suite ?? "app"})`;
|
||||
}
|
||||
return owner?.target ? `Gulp task ${owner.target}` : "another process";
|
||||
};
|
||||
|
||||
const createLockTask = ({ file, inheritedTokenEnv, kind, label, target }) => {
|
||||
let exitToken;
|
||||
|
||||
const cleanup = () => {
|
||||
if (!exitToken) {
|
||||
return;
|
||||
}
|
||||
releaseProcessRecord(file, exitToken);
|
||||
exitToken = undefined;
|
||||
process.off("exit", cleanup);
|
||||
unregisterSignalCleanup(cleanup);
|
||||
};
|
||||
const acquire = async () => {
|
||||
const inheritedToken = process.env[inheritedTokenEnv];
|
||||
if (inheritedToken) {
|
||||
if (readProcessRecord(file)?.token !== inheritedToken) {
|
||||
throw Error(
|
||||
`${label} lock ownership was lost before ${target} started.`
|
||||
);
|
||||
}
|
||||
exitToken = inheritedToken;
|
||||
process.once("exit", cleanup);
|
||||
registerSignalCleanup(cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const record = {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
processGroup: false,
|
||||
kind,
|
||||
target,
|
||||
token,
|
||||
};
|
||||
const result = acquireProcessRecord(file, record);
|
||||
if (!result.acquired) {
|
||||
const pid = result.existing?.pid;
|
||||
throw Error(
|
||||
`Cannot run ${target}: ${describeOutputOwner(result.existing)} ` +
|
||||
`already owns ${label}${pid ? ` (pid ${pid})` : ""}.`
|
||||
);
|
||||
}
|
||||
|
||||
exitToken = token;
|
||||
process.once("exit", cleanup);
|
||||
registerSignalCleanup(cleanup);
|
||||
};
|
||||
|
||||
acquire.displayName = `lock-${label}:${target}`;
|
||||
return acquire;
|
||||
};
|
||||
|
||||
export const createWorkflowLockTask = (target) =>
|
||||
createLockTask({
|
||||
file: workflowLockFile,
|
||||
inheritedTokenEnv: WORKFLOW_LOCK_TOKEN_ENV,
|
||||
kind: "output",
|
||||
label: "build and development workflow",
|
||||
target,
|
||||
});
|
||||
@@ -61,10 +61,44 @@ fi
|
||||
echo Core is used from ${coreUrl}
|
||||
|
||||
# build the frontend so it connects to the passed core
|
||||
HASS_URL="$coreUrl" ./script/develop &
|
||||
HASS_URL="$coreUrl" ./node_modules/.bin/gulp develop-app &
|
||||
develop_pid=$!
|
||||
|
||||
# serve the frontend
|
||||
./node_modules/.bin/serve -p $frontendPort --single --no-port-switching --config ../script/serve-config.json ./hass_frontend &
|
||||
serve_pid=$!
|
||||
|
||||
# keep the script running while serving
|
||||
wait
|
||||
stop_children() {
|
||||
trap - EXIT INT TERM HUP
|
||||
kill "$develop_pid" "$serve_pid" 2>/dev/null || true
|
||||
wait "$develop_pid" 2>/dev/null || true
|
||||
wait "$serve_pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap stop_children EXIT
|
||||
trap 'stop_children; exit 130' INT
|
||||
trap 'stop_children; exit 143' TERM
|
||||
trap 'stop_children; exit 129' HUP
|
||||
|
||||
while kill -0 "$develop_pid" 2>/dev/null && kill -0 "$serve_pid" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
develop_status=
|
||||
serve_status=
|
||||
if ! kill -0 "$develop_pid" 2>/dev/null; then
|
||||
if wait "$develop_pid"; then develop_status=0; else develop_status=$?; fi
|
||||
fi
|
||||
if ! kill -0 "$serve_pid" 2>/dev/null; then
|
||||
if wait "$serve_pid"; then serve_status=0; else serve_status=$?; fi
|
||||
fi
|
||||
|
||||
if [ -n "$develop_status" ] && [ "$develop_status" -ne 0 ]; then
|
||||
status=$develop_status
|
||||
elif [ -n "$serve_status" ]; then
|
||||
status=$serve_status
|
||||
else
|
||||
status=$develop_status
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { processStartTime } from "../../build-scripts/managed-process.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../.."
|
||||
);
|
||||
const temporaryDirectories = [];
|
||||
|
||||
const runNode = (args, env = {}) =>
|
||||
new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
args,
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, ...env },
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
resolve({ code: error?.code ?? 0, stdout, stderr });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const temporaryDirectory = async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ha-build-cli-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
};
|
||||
|
||||
const writeOwner = async (owner) => {
|
||||
const cache = await temporaryDirectory();
|
||||
const lockFile = path.join(cache, "ha-workflow.lock");
|
||||
const record = {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
...owner,
|
||||
};
|
||||
await writeFile(lockFile, JSON.stringify(record));
|
||||
return { cache, lockFile, record };
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe("build management CLIs", () => {
|
||||
it("blocks a managed workflow when another owns the output", async () => {
|
||||
const { cache } = await writeOwner({ kind: "build", token: "build" });
|
||||
|
||||
const result = await runNode(
|
||||
["build-scripts/dev-server.mjs", "--suite", "app", "--background"],
|
||||
{ HA_BUILD_CACHE_DIR: cache }
|
||||
);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stdout).toContain("Frontend build already running");
|
||||
});
|
||||
|
||||
it("does not stop another development suite", async () => {
|
||||
const { cache, lockFile, record } = await writeOwner({
|
||||
kind: "dev",
|
||||
suite: "demo",
|
||||
token: "demo",
|
||||
});
|
||||
|
||||
const result = await runNode(
|
||||
["build-scripts/dev-server.mjs", "--suite", "gallery", "--stop"],
|
||||
{ HA_BUILD_CACHE_DIR: cache }
|
||||
);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("Dev server (gallery) not running");
|
||||
expect(JSON.parse(await readFile(lockFile, "utf8"))).toEqual(record);
|
||||
});
|
||||
|
||||
it("keeps an exact process suite start idempotent", async () => {
|
||||
const { cache } = await writeOwner({
|
||||
kind: "dev",
|
||||
suite: "app",
|
||||
token: "app",
|
||||
});
|
||||
|
||||
const result = await runNode(
|
||||
["build-scripts/dev-server.mjs", "--suite", "app", "--background"],
|
||||
{ HA_BUILD_CACHE_DIR: cache }
|
||||
);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("Dev server (app) already running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow ownership", () => {
|
||||
it("removes owned locks when the child is terminated", async () => {
|
||||
const cache = await temporaryDirectory();
|
||||
const lockFile = path.join(cache, "ha-workflow.lock");
|
||||
const script = [
|
||||
'import { createWorkflowLockTask } from "./build-scripts/output-lock.mjs";',
|
||||
'await createWorkflowLockTask("test")();',
|
||||
'process.stdout.write("ready\\n");',
|
||||
"setInterval(() => {}, 10000);",
|
||||
].join("");
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["--input-type=module", "-e", script],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, HA_BUILD_CACHE_DIR: cache },
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
}
|
||||
);
|
||||
await new Promise((resolve, reject) => {
|
||||
child.stdout.once("data", resolve);
|
||||
child.once("error", reject);
|
||||
});
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => {
|
||||
child.once("exit", resolve);
|
||||
});
|
||||
|
||||
await expect(readFile(lockFile)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
acquireProcessRecord,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
} from "../../build-scripts/managed-process.mjs";
|
||||
|
||||
const temporaryDirectories = [];
|
||||
|
||||
const temporaryFile = async (name) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ha-build-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return path.join(directory, name);
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe("managed process records", () => {
|
||||
it("recovers a stale owner", async () => {
|
||||
const file = await temporaryFile("stale.lock");
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({ pid: 2147483647, startTime: "stale", token: "stale" })
|
||||
);
|
||||
const owner = {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
token: "current",
|
||||
};
|
||||
|
||||
expect(acquireProcessRecord(file, owner).acquired).toBe(true);
|
||||
expect(readProcessRecord(file)).toEqual(owner);
|
||||
});
|
||||
|
||||
it("releases only for the owning token", async () => {
|
||||
const file = await temporaryFile("token.lock");
|
||||
const owner = {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
token: "owner",
|
||||
};
|
||||
acquireProcessRecord(file, owner);
|
||||
|
||||
releaseProcessRecord(file, "other");
|
||||
expect(readProcessRecord(file)).toEqual(owner);
|
||||
releaseProcessRecord(file, owner.token);
|
||||
expect(readProcessRecord(file)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -1,5 +1,11 @@
|
||||
global.window = (global.window ?? {}) as any;
|
||||
global.navigator = (global.navigator ?? {}) as any;
|
||||
if (!global.navigator) {
|
||||
Object.defineProperty(global, "navigator", {
|
||||
value: {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
global.__DEMO__ = false;
|
||||
global.__DEV__ = false;
|
||||
|
||||
Reference in New Issue
Block a user