mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-31 03:18:22 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3de4b84ac2 | |||
| dacb729401 | |||
| 18e8dcac6b | |||
| 76fdd0fa48 | |||
| 8969fd48ae | |||
| c69b1c969f | |||
| cb872c4479 | |||
| 1b5b10798f | |||
| 54ad38e0a2 | |||
| b5cbda038f | |||
| bef89ea7ea | |||
| 03e8614547 | |||
| 571972f858 | |||
| 9c6e7b19dd | |||
| eed24f46e0 |
@@ -45,9 +45,7 @@ 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. 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.
|
||||
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/`.
|
||||
|
||||
## Unit And Utility Tests
|
||||
|
||||
@@ -80,7 +78,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 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.
|
||||
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.
|
||||
|
||||
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
|
||||
|
||||
|
||||
@@ -25,26 +25,24 @@ 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(buildCacheDir, "ha-build");
|
||||
const stateDir = path.join(repoRoot, "node_modules", ".cache", "ha-build");
|
||||
const logFile = path.join(stateDir, "build.log");
|
||||
const lockFile = workflowLockFile;
|
||||
const lockFile = path.join(
|
||||
repoRoot,
|
||||
"node_modules",
|
||||
".cache",
|
||||
"ha-generated-output.lock"
|
||||
);
|
||||
|
||||
const usage = () => {
|
||||
process.stderr.write(
|
||||
@@ -81,21 +79,6 @@ 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);
|
||||
@@ -132,15 +115,8 @@ 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 = devCommand(existing.suite);
|
||||
const command = existing.suite === "app-serve" ? "dev:serve" : "dev";
|
||||
process.stdout.write(
|
||||
`Dev server (${existing.suite}) already running` +
|
||||
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` +
|
||||
@@ -167,7 +143,6 @@ const runForeground = async (modern) => {
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateBuild(lock.token, child, true),
|
||||
});
|
||||
@@ -182,13 +157,11 @@ const runBackground = async (modern) => {
|
||||
reportExisting(lock.existing);
|
||||
return 1;
|
||||
}
|
||||
let child;
|
||||
try {
|
||||
child = await spawnDetachedToLog({
|
||||
const child = await spawnDetachedToLog({
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
updateBuild(lock.token, child, true);
|
||||
@@ -198,9 +171,6 @@ const runBackground = async (modern) => {
|
||||
);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
if (child) {
|
||||
await terminateDetachedProcess(child);
|
||||
}
|
||||
releaseBuild(lock.token);
|
||||
throw err;
|
||||
}
|
||||
@@ -275,7 +245,11 @@ const main = async () => {
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
|
||||
if (
|
||||
args.modes.length > 1 ||
|
||||
(args.follow && args.mode !== "logs") ||
|
||||
(args.modern && !["foreground", "background"].includes(args.mode))
|
||||
) {
|
||||
process.stderr.write("Invalid combination of build arguments.\n");
|
||||
usage();
|
||||
return 1;
|
||||
|
||||
+243
-162
@@ -14,10 +14,13 @@
|
||||
//
|
||||
// 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).
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -29,20 +32,15 @@ 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)),
|
||||
@@ -54,7 +52,13 @@ const developAndServeScript = path.join(
|
||||
"script",
|
||||
"develop_and_serve"
|
||||
);
|
||||
const logDir = path.join(buildCacheDir, "ha-dev-server");
|
||||
const logDir = path.join(repoRoot, "node_modules", ".cache", "ha-dev-server");
|
||||
const outputLockFile = path.join(
|
||||
repoRoot,
|
||||
"node_modules",
|
||||
".cache",
|
||||
"ha-generated-output.lock"
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -94,6 +98,7 @@ const SUITES = new Map([
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
||||
processKey: "app",
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -104,6 +109,7 @@ const SUITES = new Map([
|
||||
acceptsArgs: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
processKey: "app",
|
||||
},
|
||||
],
|
||||
]);
|
||||
@@ -179,7 +185,27 @@ const parseArgs = (argv) => {
|
||||
};
|
||||
|
||||
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
|
||||
const acquireSuite = (suite) => {
|
||||
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 token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const record = {
|
||||
pid: process.pid,
|
||||
@@ -189,37 +215,48 @@ const acquireSuite = (suite) => {
|
||||
starting: true,
|
||||
token,
|
||||
};
|
||||
const result = acquireProcessRecord(workflowLockFile, record);
|
||||
return result.acquired ? { token } : { existing: result.existing };
|
||||
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 updateSuite = (suite, token, child, port) => {
|
||||
const existing = readProcessRecord(workflowLockFile);
|
||||
const updateProcessSuite = (suite, token, child) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing?.token !== token) {
|
||||
throw Error(`Dev server (${suite}) ownership was lost during startup.`);
|
||||
throw Error(
|
||||
`Dev server (${suite}) process ownership was lost during startup.`
|
||||
);
|
||||
}
|
||||
writeProcessRecord(workflowLockFile, {
|
||||
const record = {
|
||||
...existing,
|
||||
pid: child.pid,
|
||||
startTime: processStartTime(child.pid),
|
||||
processGroup: true,
|
||||
starting: false,
|
||||
port,
|
||||
});
|
||||
};
|
||||
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);
|
||||
};
|
||||
|
||||
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 releaseProcessSuite = (suite, token) => {
|
||||
releaseProcessRecord(outputLockFile, token, () => {
|
||||
if (readPidFile(suite)?.token === token) {
|
||||
removeProcessRecord(pidFileFor(suite));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const hints = (suite) => {
|
||||
@@ -232,13 +269,6 @@ 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})` : ""}. ` +
|
||||
@@ -254,22 +284,6 @@ 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}` : "");
|
||||
@@ -380,116 +394,163 @@ const probe = async (port, timeoutMs = 1000) => {
|
||||
return probeHost(0, false);
|
||||
};
|
||||
|
||||
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);
|
||||
// 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) {
|
||||
try {
|
||||
const response = await fetch(`http://${host}:${port}`, {
|
||||
signal: controller.signal,
|
||||
const out = execFileSync(cmd, cmdArgs, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
if (response.ok) {
|
||||
return true;
|
||||
const pid = Number(extract(out));
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
return pid;
|
||||
}
|
||||
} catch {
|
||||
// Try the next address.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
// Try the next tool.
|
||||
}
|
||||
return probeHost(index + 1);
|
||||
};
|
||||
return probeHost(0);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const runForegroundHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
return lock.code;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (status.state === "ours") {
|
||||
releaseSuite(lock.token);
|
||||
process.stderr.write(
|
||||
`Port ${port} is already serving the ${status.suite ?? "unknown"} dev server.\n`
|
||||
`Port ${port} is serving the ${status.suite ?? "unknown"} dev server; not ${suite}.\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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
return spawnForeground({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: cfg.spawn.args,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
};
|
||||
|
||||
const runBackgroundHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
return lock.code;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (preflight.state === "ours") {
|
||||
releaseSuite(lock.token);
|
||||
process.stderr.write(
|
||||
`Port ${port} is already serving the ${preflight.suite ?? "unknown"} dev server.\n`
|
||||
`Port ${port} is serving the ${preflight.suite ?? "unknown"} dev server; not ${suite}.\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;
|
||||
}
|
||||
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;
|
||||
|
||||
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`);
|
||||
}
|
||||
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) => {
|
||||
@@ -521,79 +582,101 @@ const spawnArgs = (cfg, passthrough) => [
|
||||
];
|
||||
|
||||
const runForegroundProcess = async (suite, cfg, passthrough) => {
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
const lock = acquireProcessSuite(suite);
|
||||
if (!lock.token) {
|
||||
return lock.code;
|
||||
reportProcessConflict(suite, lock.existing);
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return await spawnForeground({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: spawnArgs(cfg, passthrough),
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateSuite(suite, lock.token, child),
|
||||
onSpawn: (child) => updateProcessSuite(suite, lock.token, child),
|
||||
});
|
||||
} finally {
|
||||
releaseSuite(lock.token);
|
||||
releaseProcessSuite(suite, lock.token);
|
||||
}
|
||||
};
|
||||
|
||||
const runBackgroundProcess = async (suite, cfg, passthrough) => {
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
const lock = acquireProcessSuite(suite);
|
||||
if (!lock.token) {
|
||||
return lock.code;
|
||||
reportProcessConflict(suite, lock.existing);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let child;
|
||||
try {
|
||||
const logFile = logFileFor(suite);
|
||||
child = await spawnDetachedToLog({
|
||||
const 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;
|
||||
updateSuite(suite, lock.token, child, port);
|
||||
updateProcessSuite(suite, lock.token, child);
|
||||
writePidFile(suite, { ...readPidFile(suite), port });
|
||||
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: async () =>
|
||||
logIsReady(logFile, cfg.readyLog) &&
|
||||
(!cfg.acceptsArgs || (await isHttpServing(port))),
|
||||
onExit: () => releaseSuite(lock.token),
|
||||
isReady: () => logIsReady(logFile, cfg.readyLog),
|
||||
onExit: () => releaseProcessSuite(suite, lock.token),
|
||||
});
|
||||
} catch (err) {
|
||||
if (child) {
|
||||
await terminateDetachedProcess(child);
|
||||
}
|
||||
releaseSuite(lock.token);
|
||||
releaseProcessSuite(suite, lock.token);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const runStatusSuite = async (suite, cfg) => {
|
||||
const existing = readSuite(suite);
|
||||
if (existing) {
|
||||
const runStatusProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isProcessRecordAlive(existing)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${existing.suite ?? suite}) running${urlSuffix(existing.port ?? cfg.port)} ` +
|
||||
`Dev server (${existing.suite ?? suite}) running${urlSuffix(existing.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 runStopSuite = async (suite) => {
|
||||
const existing = readSuite(suite);
|
||||
if (!existing) {
|
||||
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)
|
||||
);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
return 0;
|
||||
}
|
||||
@@ -603,14 +686,14 @@ const runStopSuite = async (suite) => {
|
||||
activeSuite,
|
||||
pid,
|
||||
() => !isProcessRecordAlive(existing),
|
||||
() => releaseSuite(existing.token)
|
||||
() => releaseProcessSuite(activeSuite, existing.token)
|
||||
);
|
||||
};
|
||||
|
||||
// --- shared -----------------------------------------------------------------
|
||||
|
||||
const runLogs = (suite, follow) => {
|
||||
const activeSuite = readSuite(suite)?.suite ?? suite;
|
||||
const activeSuite = readPidFile(suite)?.suite ?? suite;
|
||||
return outputLog(
|
||||
logFileFor(activeSuite),
|
||||
follow,
|
||||
@@ -656,23 +739,21 @@ 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,6 +1,5 @@
|
||||
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";
|
||||
@@ -18,7 +17,6 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-app"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-service-worker-app-dev",
|
||||
@@ -38,7 +36,6 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-app"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
@@ -60,7 +57,6 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-app-modern"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import gulp from "gulp";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
@@ -14,7 +13,6 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-demo"),
|
||||
"clean-demo",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -34,7 +32,6 @@ 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,5 +1,4 @@
|
||||
import gulp from "gulp";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
@@ -13,7 +12,6 @@ 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(
|
||||
@@ -33,7 +31,6 @@ 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,7 +4,6 @@ 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";
|
||||
@@ -165,7 +164,6 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-gallery"),
|
||||
"clean-gallery",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -197,7 +195,6 @@ 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 = () => (err, stats) => {
|
||||
const doneHandler = (done) => (err, stats) => {
|
||||
if (err) {
|
||||
log.error(err.stack || err);
|
||||
if (err.details) {
|
||||
@@ -122,26 +122,18 @@ const doneHandler = () => (err, stats) => {
|
||||
}
|
||||
|
||||
log(`Build done @ ${new Date().toLocaleTimeString()}`);
|
||||
|
||||
if (done) {
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
const prodBuild = (conf) =>
|
||||
new Promise((resolve, reject) => {
|
||||
new Promise((resolve) => {
|
||||
rspack(
|
||||
conf,
|
||||
// Resolve promise when done. Because we pass a callback, rspack closes itself
|
||||
(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();
|
||||
}
|
||||
}
|
||||
doneHandler(resolve)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import { deleteAsync } from "del";
|
||||
import { glob } from "glob";
|
||||
import gulp from "gulp";
|
||||
|
||||
@@ -212,7 +212,6 @@ export const spawnForeground = ({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
processGroup = false,
|
||||
onSpawn,
|
||||
}) =>
|
||||
@@ -220,7 +219,6 @@ export const spawnForeground = ({
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: processGroup,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
let settled = false;
|
||||
@@ -267,7 +265,7 @@ export const spawnForeground = ({
|
||||
});
|
||||
});
|
||||
|
||||
export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
|
||||
export const spawnDetachedToLog = ({ cmd, args, cwd, logFile }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
||||
const fd = fs.openSync(logFile, "w");
|
||||
@@ -276,7 +274,6 @@ export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
|
||||
child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: true,
|
||||
env,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
} finally {
|
||||
@@ -320,13 +317,6 @@ 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);
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
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,44 +61,10 @@ fi
|
||||
echo Core is used from ${coreUrl}
|
||||
|
||||
# build the frontend so it connects to the passed core
|
||||
HASS_URL="$coreUrl" ./node_modules/.bin/gulp develop-app &
|
||||
develop_pid=$!
|
||||
HASS_URL="$coreUrl" ./script/develop &
|
||||
|
||||
# serve the frontend
|
||||
./node_modules/.bin/serve -p $frontendPort --single --no-port-switching --config ../script/serve-config.json ./hass_frontend &
|
||||
serve_pid=$!
|
||||
|
||||
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"
|
||||
# keep the script running while serving
|
||||
wait
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface LovelaceViewElement extends HTMLElement {
|
||||
badges?: HuiBadge[];
|
||||
sections?: HuiSection[];
|
||||
isStrategy: boolean;
|
||||
initialRenderComplete?: Promise<void>;
|
||||
setConfig(config: LovelaceViewConfig): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../components/animation/ha-fade-in";
|
||||
import "../components/ha-top-app-bar-fixed";
|
||||
import "../components/ha-spinner";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -36,7 +37,9 @@ class HassLoadingScreen extends LitElement {
|
||||
private _renderContent(): TemplateResult {
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-spinner></ha-spinner>
|
||||
<ha-fade-in .delay=${500}>
|
||||
<ha-spinner></ha-spinner>
|
||||
</ha-fade-in>
|
||||
${
|
||||
this.message
|
||||
? html`<div id="loading-text">${this.message}</div>`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ContextProvider, createContext } from "@lit/context";
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
|
||||
@@ -15,6 +17,40 @@ export const panelIsReady = async (element: HTMLElement) => {
|
||||
fireEvent(element, "hass-panel-ready");
|
||||
};
|
||||
|
||||
export type RegisterChildPanelReady = (ready: Promise<void>) => void;
|
||||
|
||||
export const childPanelReadyContext =
|
||||
createContext<RegisterChildPanelReady>("child-panel-ready");
|
||||
|
||||
export class ChildPanelReady implements ReactiveController {
|
||||
private _promises: Promise<void>[] = [];
|
||||
|
||||
private _host: ReactiveControllerHost & HTMLElement;
|
||||
|
||||
private _resolveReady?: () => void;
|
||||
|
||||
public ready = new Promise<void>((resolve) => {
|
||||
this._resolveReady = resolve;
|
||||
});
|
||||
|
||||
public constructor(host: ReactiveControllerHost & HTMLElement) {
|
||||
this._host = host;
|
||||
host.addController(this);
|
||||
new ContextProvider(host, {
|
||||
context: childPanelReadyContext,
|
||||
initialValue: (ready) => this._promises.push(ready),
|
||||
});
|
||||
}
|
||||
|
||||
public hostUpdated() {
|
||||
Promise.allSettled(this._promises).then(() => {
|
||||
this._resolveReady?.();
|
||||
return panelIsReady(this._host);
|
||||
});
|
||||
this._host.removeController(this);
|
||||
}
|
||||
}
|
||||
|
||||
export class PanelReady {
|
||||
public ready?: Promise<void>;
|
||||
|
||||
|
||||
@@ -19,29 +19,62 @@ import { HassRouterPage } from "./hass-router-page";
|
||||
|
||||
const CACHE_URL_PATHS = ["lovelace", "home", "config"];
|
||||
const PANEL_READY_TIMEOUT = 2000;
|
||||
const DASHBOARD_READY_TIMEOUT = 5000;
|
||||
const COMPONENTS = {
|
||||
app: () => import("../panels/app/ha-panel-app"),
|
||||
energy: () => import("../panels/energy/ha-panel-energy"),
|
||||
calendar: () => import("../panels/calendar/ha-panel-calendar"),
|
||||
config: () => import("../panels/config/ha-panel-config"),
|
||||
custom: () => import("../panels/custom/ha-panel-custom"),
|
||||
lovelace: () => import("../panels/lovelace/ha-panel-lovelace"),
|
||||
history: () => import("../panels/history/ha-panel-history"),
|
||||
iframe: () => import("../panels/iframe/ha-panel-iframe"),
|
||||
logbook: () => import("../panels/logbook/ha-panel-logbook"),
|
||||
map: () => import("../panels/map/ha-panel-map"),
|
||||
my: () => import("../panels/my/ha-panel-my"),
|
||||
profile: () => import("../panels/profile/ha-panel-profile"),
|
||||
todo: () => import("../panels/todo/ha-panel-todo"),
|
||||
"media-browser": () =>
|
||||
import("../panels/media-browser/ha-panel-media-browser"),
|
||||
light: () => import("../panels/light/ha-panel-light"),
|
||||
security: () => import("../panels/security/ha-panel-security"),
|
||||
climate: () => import("../panels/climate/ha-panel-climate"),
|
||||
maintenance: () => import("../panels/maintenance/ha-panel-maintenance"),
|
||||
home: () => import("../panels/home/ha-panel-home"),
|
||||
notfound: () => import("../panels/notfound/ha-panel-notfound"),
|
||||
};
|
||||
app: { load: () => import("../panels/app/ha-panel-app") },
|
||||
energy: {
|
||||
load: () => import("../panels/energy/ha-panel-energy"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
calendar: { load: () => import("../panels/calendar/ha-panel-calendar") },
|
||||
config: { load: () => import("../panels/config/ha-panel-config") },
|
||||
custom: { load: () => import("../panels/custom/ha-panel-custom") },
|
||||
lovelace: {
|
||||
load: () => import("../panels/lovelace/ha-panel-lovelace"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
history: { load: () => import("../panels/history/ha-panel-history") },
|
||||
iframe: { load: () => import("../panels/iframe/ha-panel-iframe") },
|
||||
logbook: { load: () => import("../panels/logbook/ha-panel-logbook") },
|
||||
map: { load: () => import("../panels/map/ha-panel-map") },
|
||||
my: { load: () => import("../panels/my/ha-panel-my") },
|
||||
profile: { load: () => import("../panels/profile/ha-panel-profile") },
|
||||
todo: { load: () => import("../panels/todo/ha-panel-todo") },
|
||||
"media-browser": {
|
||||
load: () => import("../panels/media-browser/ha-panel-media-browser"),
|
||||
},
|
||||
light: {
|
||||
load: () => import("../panels/light/ha-panel-light"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
security: {
|
||||
load: () => import("../panels/security/ha-panel-security"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
climate: {
|
||||
load: () => import("../panels/climate/ha-panel-climate"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
maintenance: {
|
||||
load: () => import("../panels/maintenance/ha-panel-maintenance"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
home: {
|
||||
load: () => import("../panels/home/ha-panel-home"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
notfound: { load: () => import("../panels/notfound/ha-panel-notfound") },
|
||||
} satisfies Record<
|
||||
string,
|
||||
Pick<RouteOptions, "load" | "waitForReady"> & { readyTimeout?: number }
|
||||
>;
|
||||
|
||||
@customElement("partial-panel-resolver")
|
||||
class PartialPanelResolver extends HassRouterPage {
|
||||
@@ -126,13 +159,12 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
private _getRoutes(panels: Panels): RouterOptions {
|
||||
const routes: RouterOptions["routes"] = {};
|
||||
Object.values(panels).forEach((panel) => {
|
||||
const component = COMPONENTS[panel.component_name];
|
||||
const data: RouteOptions = {
|
||||
tag: `ha-panel-${panel.component_name}`,
|
||||
cache: CACHE_URL_PATHS.includes(panel.url_path),
|
||||
...component,
|
||||
};
|
||||
if (panel.component_name in COMPONENTS) {
|
||||
data.load = COMPONENTS[panel.component_name];
|
||||
}
|
||||
routes[panel.url_path] = data;
|
||||
});
|
||||
|
||||
@@ -221,9 +253,12 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
)
|
||||
) {
|
||||
await this.rebuild();
|
||||
await promiseTimeout(PANEL_READY_TIMEOUT, this.pageRendered).catch(
|
||||
() => undefined
|
||||
);
|
||||
const panel = this.hass.panels[this._currentPage];
|
||||
const component = panel ? COMPONENTS[panel.component_name] : undefined;
|
||||
await promiseTimeout(
|
||||
component?.readyTimeout ?? PANEL_READY_TIMEOUT,
|
||||
this.pageRendered
|
||||
).catch(() => undefined);
|
||||
// Only fire frontend/loaded when this call actually removed the launch
|
||||
// screen, so later panel updates do not fire it again. Native apps remove
|
||||
// it instantly because their own splash screen is still visible.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -31,6 +32,8 @@ class PanelClimate extends LitElement {
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -127,6 +130,7 @@ class PanelClimate extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -36,6 +36,7 @@ import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
|
||||
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
|
||||
import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import { ChildPanelReady } from "../../../layouts/panel-ready";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -157,6 +158,11 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
total: 0,
|
||||
};
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
new ChildPanelReady(this);
|
||||
}
|
||||
|
||||
private _pages = memoizeOne(
|
||||
(
|
||||
cloudStatus,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { consume } from "@lit/context";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { filterNavigationPages } from "../../../common/config/filter_navigation_pages";
|
||||
@@ -7,6 +8,10 @@ import "../../../components/ha-icon-next";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import { getConfigEntries } from "../../../data/config_entries";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import {
|
||||
childPanelReadyContext,
|
||||
type RegisterChildPanelReady,
|
||||
} from "../../../layouts/panel-ready";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../components/ha-config-navigation-list";
|
||||
|
||||
@@ -18,21 +23,31 @@ class HaConfigNavigation extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public pages!: PageNavigation[];
|
||||
|
||||
@state() private _hasBluetoothConfigEntries = false;
|
||||
@state() private _visiblePages?: PageNavigation[];
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
getConfigEntries(this.hass, {
|
||||
domain: "bluetooth",
|
||||
}).then((bluetoothEntries) => {
|
||||
this._hasBluetoothConfigEntries = bluetoothEntries.length > 0;
|
||||
});
|
||||
private _hasBluetoothConfigEntries = false;
|
||||
|
||||
private _bluetoothEntriesLoaded?: Promise<void>;
|
||||
|
||||
private _childReadyRegistered = false;
|
||||
|
||||
@consume({ context: childPanelReadyContext })
|
||||
private _registerChildPanelReady?: RegisterChildPanelReady;
|
||||
|
||||
protected override updated(changedProps: Map<PropertyKey, unknown>) {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (changedProps.has("pages") || changedProps.has("hass")) {
|
||||
const ready = this._resolveVisiblePages();
|
||||
if (!this._childReadyRegistered) {
|
||||
this._registerChildPanelReady?.(ready);
|
||||
this._childReadyRegistered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const pages = filterNavigationPages(this.hass, this.pages, {
|
||||
hasBluetoothConfigEntries: this._hasBluetoothConfigEntries,
|
||||
}).map((page) => ({
|
||||
const pages = (this._visiblePages ?? []).map((page) => ({
|
||||
...page,
|
||||
name:
|
||||
page.name ||
|
||||
@@ -75,6 +90,28 @@ class HaConfigNavigation extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _loadBluetoothEntries(): Promise<void> {
|
||||
if (!this._bluetoothEntriesLoaded) {
|
||||
this._bluetoothEntriesLoaded = getConfigEntries(this.hass, {
|
||||
domain: "bluetooth",
|
||||
}).then((entries) => {
|
||||
this._hasBluetoothConfigEntries = entries.length > 0;
|
||||
});
|
||||
}
|
||||
return this._bluetoothEntriesLoaded;
|
||||
}
|
||||
|
||||
private async _resolveVisiblePages(): Promise<void> {
|
||||
if (this.pages.some((page) => page.component === "bluetooth")) {
|
||||
await this._loadBluetoothEntries();
|
||||
}
|
||||
|
||||
this._visiblePages = filterNavigationPages(this.hass, this.pages, {
|
||||
hasBluetoothConfigEntries: this._hasBluetoothConfigEntries,
|
||||
});
|
||||
await this.updateComplete;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
/* Accessibility */
|
||||
.visually-hidden {
|
||||
|
||||
@@ -89,6 +89,7 @@ class HaPanelConfig extends HassRouterPage {
|
||||
dashboard: {
|
||||
tag: "ha-config-dashboard",
|
||||
load: () => import("./dashboard/ha-config-dashboard"),
|
||||
waitForReady: true,
|
||||
},
|
||||
entities: {
|
||||
tag: "ha-config-entities",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -31,6 +32,8 @@ class PanelLight extends LitElement {
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -127,6 +130,7 @@ class PanelLight extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/animation/ha-fade-in";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-spinner";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
@@ -38,7 +39,9 @@ export class HuiStartingCard extends LitElement implements LovelaceCard {
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-spinner></ha-spinner>
|
||||
<ha-fade-in .delay=${500}>
|
||||
<ha-spinner></ha-spinner>
|
||||
</ha-fade-in>
|
||||
${this.hass.localize("ui.panel.lovelace.cards.starting.description")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -76,6 +76,7 @@ import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-di
|
||||
import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar";
|
||||
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
@@ -164,6 +165,8 @@ class HUIRoot extends LitElement {
|
||||
|
||||
private _restoreScroll = false;
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
private _undoRedoController = new UndoRedoController<UndoStackItem>(this, {
|
||||
apply: (config) => this._applyUndoRedo(config),
|
||||
currentConfig: () => ({
|
||||
@@ -777,7 +780,7 @@ class HUIRoot extends LitElement {
|
||||
huiView.narrow = this.narrow;
|
||||
}
|
||||
|
||||
let newSelectView;
|
||||
let newSelectView: HUIRoot["_curView"];
|
||||
|
||||
let viewPath: string | undefined = this.route!.path.split("/")[1];
|
||||
viewPath = viewPath ? decodeURI(viewPath) : undefined;
|
||||
@@ -1258,7 +1261,7 @@ class HUIRoot extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
let view;
|
||||
let view: HUIView;
|
||||
const viewConfig = this.config.views[viewIndex];
|
||||
|
||||
if (!viewConfig) {
|
||||
@@ -1269,12 +1272,16 @@ class HUIRoot extends LitElement {
|
||||
if (this._viewCache[viewIndex]) {
|
||||
view = this._viewCache[viewIndex];
|
||||
} else {
|
||||
if (!this._childPanelReady) {
|
||||
this._childPanelReady = new ChildPanelReady(this);
|
||||
this.requestUpdate();
|
||||
}
|
||||
view = document.createElement("hui-view");
|
||||
view.index = viewIndex;
|
||||
this._viewCache[viewIndex] = view;
|
||||
}
|
||||
|
||||
view.lovelace = this.lovelace;
|
||||
view.lovelace = this.lovelace!;
|
||||
view.hass = this.hass;
|
||||
view.narrow = this.narrow;
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
|
||||
private _mqlListenerRef?: () => void;
|
||||
|
||||
private _resolveInitialRender?: () => void;
|
||||
|
||||
public initialRenderComplete = new Promise<void>((resolve) => {
|
||||
this._resolveInitialRender = resolve;
|
||||
});
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._initMqls();
|
||||
@@ -166,7 +172,13 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
root.removeChild(root.lastChild);
|
||||
}
|
||||
|
||||
columns.forEach((column) => root.appendChild(column));
|
||||
columns.forEach((column) => {
|
||||
root.appendChild(column);
|
||||
});
|
||||
if (this.cards.length === 0 || columns.some((column) => column.lastChild)) {
|
||||
this._resolveInitialRender?.();
|
||||
this._resolveInitialRender = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async _createColumns() {
|
||||
@@ -234,6 +246,10 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
index,
|
||||
this.lovelace!.editMode
|
||||
);
|
||||
if (columnElements.some((column) => column.isConnected)) {
|
||||
this._resolveInitialRender?.();
|
||||
this._resolveInitialRender = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty columns
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import deepClone from "deep-clone-simple";
|
||||
import { consume } from "@lit/context";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -19,6 +20,10 @@ import type {
|
||||
} from "../../../data/lovelace/config/view";
|
||||
import { isStrategyView } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
childPanelReadyContext,
|
||||
type RegisterChildPanelReady,
|
||||
} from "../../../layouts/panel-ready";
|
||||
import "../badges/hui-badge";
|
||||
import type { HuiBadge } from "../badges/hui-badge";
|
||||
import "../cards/hui-card";
|
||||
@@ -95,6 +100,15 @@ export class HUIView extends ReactiveElement {
|
||||
|
||||
private _config?: LovelaceViewConfig;
|
||||
|
||||
private _resolveInitialRender?: () => void;
|
||||
|
||||
private _initialRenderComplete = new Promise<void>((resolve) => {
|
||||
this._resolveInitialRender = resolve;
|
||||
});
|
||||
|
||||
@consume({ context: childPanelReadyContext })
|
||||
private _registerChildPanelReady?: RegisterChildPanelReady;
|
||||
|
||||
@storage({
|
||||
key: "dashboardCardClipboard",
|
||||
state: false,
|
||||
@@ -152,6 +166,11 @@ export class HUIView extends ReactiveElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._registerChildPanelReady?.(this._initialRenderComplete);
|
||||
}
|
||||
|
||||
public willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
|
||||
@@ -324,6 +343,13 @@ export class HUIView extends ReactiveElement {
|
||||
const viewConfig = await this._generateConfig(rawConfig);
|
||||
|
||||
this._setConfig(viewConfig, isStrategy);
|
||||
await customElements.whenDefined(this._layoutElement!.localName);
|
||||
if (this._layoutElement instanceof ReactiveElement) {
|
||||
await this._layoutElement.updateComplete;
|
||||
}
|
||||
await this._layoutElement!.initialRenderComplete;
|
||||
this._resolveInitialRender?.();
|
||||
this._resolveInitialRender = undefined;
|
||||
}
|
||||
|
||||
private _createLayoutElement(config: LovelaceViewConfig): void {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -31,6 +32,8 @@ class PanelMaintenance extends LitElement {
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -127,6 +130,7 @@ class PanelMaintenance extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -31,6 +32,8 @@ class PanelSecurity extends LitElement {
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -127,6 +130,7 @@ class PanelSecurity extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* @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" });
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -166,6 +166,40 @@ defineRouteSmokeTests(appRouteSmokeGroups);
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Lovelace dashboard", () => {
|
||||
test("keeps the launch screen until generated content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToPanel(page, "/?scenario=delayed-generated-dashboard#/climate");
|
||||
|
||||
const launchScreen = page.locator("#ha-launch-screen");
|
||||
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(page.locator("hui-view")).not.toBeAttached();
|
||||
|
||||
await page.evaluate(() => window.resolveGeneratedDashboard?.());
|
||||
|
||||
await expect(page.locator("hui-view")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
});
|
||||
|
||||
test("keeps the launch screen until initial content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToPanel(page, "/?scenario=delayed-lovelace#/lovelace");
|
||||
|
||||
const launchScreen = page.locator("#ha-launch-screen");
|
||||
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(page.locator("hui-card")).not.toBeAttached();
|
||||
|
||||
await page.evaluate(() => window.resolveLovelaceConfig?.());
|
||||
|
||||
await expect(page.locator("hui-card").first()).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
});
|
||||
|
||||
test("renders cards", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
// At least one card should appear
|
||||
|
||||
@@ -92,6 +92,8 @@ declare global {
|
||||
interface Window {
|
||||
__assistRun?: unknown;
|
||||
__mockHass: MockHomeAssistant;
|
||||
resolveGeneratedDashboard?: () => void;
|
||||
resolveLovelaceConfig?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ExtEntityRegistryEntry } from "../../../../../src/data/entity/entity_registry";
|
||||
import type { AssistPipeline } from "../../../../../src/data/assist_pipeline";
|
||||
import type { LovelaceRawConfig } from "../../../../../src/data/lovelace/config/types";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
|
||||
@@ -124,6 +125,50 @@ const quickSearchAssistScenario: Scenario = async (hass) => {
|
||||
});
|
||||
};
|
||||
|
||||
const addLaunchScreen = () => {
|
||||
const launchScreen = document.createElement("div");
|
||||
launchScreen.id = "ha-launch-screen";
|
||||
document.body.prepend(launchScreen);
|
||||
};
|
||||
|
||||
const delayedLovelaceScenario: Scenario = (hass) => {
|
||||
addLaunchScreen();
|
||||
|
||||
const config: LovelaceRawConfig = {
|
||||
views: [
|
||||
{
|
||||
title: "Home",
|
||||
cards: [{ type: "markdown", content: "Dashboard ready" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
let resolveConfig: ((config: LovelaceRawConfig) => void) | undefined;
|
||||
const configPromise = new Promise<LovelaceRawConfig>((resolve) => {
|
||||
resolveConfig = resolve;
|
||||
});
|
||||
|
||||
window.resolveLovelaceConfig = () => resolveConfig?.(config);
|
||||
hass.mockWS("lovelace/config", () => configPromise);
|
||||
};
|
||||
|
||||
const delayedGeneratedDashboardScenario: Scenario = (hass) => {
|
||||
addLaunchScreen();
|
||||
|
||||
const loadFragmentTranslation = hass.loadFragmentTranslation;
|
||||
let resolveTranslation: (() => void) | undefined;
|
||||
const translationReady = new Promise<void>((resolve) => {
|
||||
resolveTranslation = resolve;
|
||||
});
|
||||
|
||||
hass.loadFragmentTranslation = async (fragment) => {
|
||||
if (fragment === "lovelace") {
|
||||
await translationReady;
|
||||
}
|
||||
return loadFragmentTranslation(fragment);
|
||||
};
|
||||
window.resolveGeneratedDashboard = resolveTranslation;
|
||||
};
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const scenarios: Record<string, Scenario> = {
|
||||
@@ -131,6 +176,8 @@ export const scenarios: Record<string, Scenario> = {
|
||||
"non-admin": nonAdminScenario,
|
||||
"dark-theme": darkThemeScenario,
|
||||
"custom-theme": customThemeScenario,
|
||||
"delayed-generated-dashboard": delayedGeneratedDashboardScenario,
|
||||
"light-more-info": lightMoreInfoScenario,
|
||||
"quick-search-assist": quickSearchAssistScenario,
|
||||
"delayed-lovelace": delayedLovelaceScenario,
|
||||
};
|
||||
|
||||
+1
-7
@@ -1,11 +1,5 @@
|
||||
global.window = (global.window ?? {}) as any;
|
||||
if (!global.navigator) {
|
||||
Object.defineProperty(global, "navigator", {
|
||||
value: {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
global.navigator = (global.navigator ?? {}) as any;
|
||||
|
||||
global.__DEMO__ = false;
|
||||
global.__DEV__ = false;
|
||||
|
||||
Reference in New Issue
Block a user