Compare commits

..

15 Commits

Author SHA1 Message Date
Aidan Timson 3de4b84ac2 Type guard
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-30 13:21:05 +01:00
Aidan Timson dacb729401 Test generated dashboard initial readiness 2026-07-30 13:21:05 +01:00
Aidan Timson 18e8dcac6b Delay generated panel readiness until content 2026-07-30 13:21:05 +01:00
Aidan Timson 76fdd0fa48 Add fade in delay to correct dashboard loading 2026-07-30 13:21:05 +01:00
Aidan Timson 8969fd48ae Test dashboard initial readiness 2026-07-30 13:21:05 +01:00
Aidan Timson c69b1c969f Wait for generated panel readiness 2026-07-30 13:21:05 +01:00
Aidan Timson cb872c4479 Aggregate dashboard view readiness 2026-07-30 13:21:05 +01:00
Aidan Timson 1b5b10798f Signal initial Lovelace view readiness 2026-07-30 13:21:05 +01:00
Aidan Timson 54ad38e0a2 Wait for dashboard initial readiness 2026-07-30 13:21:05 +01:00
Aidan Timson b5cbda038f Expose initial view render completion 2026-07-30 13:21:05 +01:00
Aidan Timson bef89ea7ea Wrap dashboard loading spinner in a delayed fade in component 2026-07-30 13:21:05 +01:00
Aidan Timson 03e8614547 Cache bluetooth config entries fetch in settings navigation 2026-07-30 13:21:02 +01:00
Aidan Timson 571972f858 Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-30 13:21:02 +01:00
Aidan Timson 9c6e7b19dd Move panel readiness setup to constructor 2026-07-30 13:21:02 +01:00
Aidan Timson eed24f46e0 Wait for settings overview readiness 2026-07-30 13:21:02 +01:00
33 changed files with 589 additions and 665 deletions
+2 -4
View File
@@ -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.
+14 -40
View File
@@ -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
View File
@@ -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]();
};
-4
View File
@@ -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",
-3
View File
@@ -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",
-3
View File
@@ -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"),
-3
View File
@@ -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(
+7 -15
View File
@@ -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)
);
});
+2
View File
@@ -1,3 +1,5 @@
/* eslint-disable max-classes-per-file */
import { deleteAsync } from "del";
import { glob } from "glob";
import gulp from "gulp";
+1 -11
View File
@@ -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);
-127
View File
@@ -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,
});
+3 -37
View File
@@ -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
+1
View File
@@ -23,6 +23,7 @@ export interface LovelaceViewElement extends HTMLElement {
badges?: HuiBadge[];
sections?: HuiSection[];
isStrategy: boolean;
initialRenderComplete?: Promise<void>;
setConfig(config: LovelaceViewConfig): void;
}
+4 -1
View File
@@ -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>`
+36
View File
@@ -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>;
+63 -28
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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
View File
@@ -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>
`;
+10 -3
View File
@@ -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;
+17 -1
View File
@@ -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
+26
View File
@@ -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,
+4
View File
@@ -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,
-139
View File
@@ -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();
});
});
+34
View File
@@ -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
+2
View File
@@ -92,6 +92,8 @@ declare global {
interface Window {
__assistRun?: unknown;
__mockHass: MockHomeAssistant;
resolveGeneratedDashboard?: () => void;
resolveLovelaceConfig?: () => void;
}
}
+47
View File
@@ -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
View File
@@ -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;