mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-01 11:55:54 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1454f4d081 | |||
| 1a47c326b2 | |||
| d8e827e08b | |||
| 301c907fa9 | |||
| 1ef3bc94d0 | |||
| ba0367be2f | |||
| c186a31056 | |||
| bf8c92c95e | |||
| 9743117abf | |||
| 5e012973b2 | |||
| ea659f1b33 | |||
| 539803cb5b | |||
| 944d3332d1 | |||
| 4a267d160f | |||
| 7edb9f8164 | |||
| ff814167c6 | |||
| d08d8dde64 | |||
| 5da441ceed | |||
| 22e646a68a | |||
| d0ab20479f |
@@ -20,7 +20,6 @@ yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler, run without file arguments
|
||||
yarn test # Vitest
|
||||
yarn build # Full production build
|
||||
yarn dev # App dev server
|
||||
yarn dev:serve # Local serving dev server
|
||||
```
|
||||
@@ -29,26 +28,6 @@ Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `t
|
||||
|
||||
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
|
||||
|
||||
## Production Builds
|
||||
|
||||
Production builds support foreground and managed background execution:
|
||||
|
||||
```bash
|
||||
yarn build # Full foreground build
|
||||
yarn build --background # Full managed background build
|
||||
yarn build --modern # Modern frontend_latest bundle only
|
||||
yarn build --modern --background # Modern managed background build
|
||||
yarn build --status
|
||||
yarn build --logs [--follow]
|
||||
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.
|
||||
|
||||
## Unit And Utility Tests
|
||||
|
||||
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
|
||||
@@ -62,7 +41,7 @@ Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so on
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal. `yarn dev` and `yarn dev:serve` share one managed process slot because both write the app output.
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
@@ -80,7 +59,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.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ This is the repository for the official [Home Assistant](https://home-assistant.
|
||||
|
||||
- Initial setup: `script/setup`
|
||||
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
- Production build: `yarn build`
|
||||
- Production build: `script/build_frontend`
|
||||
- Gallery: `cd gallery && script/develop_gallery`
|
||||
|
||||
## Frontend development
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
// Manage a Home Assistant frontend production build with an agent-friendly
|
||||
// interface, matching build-scripts/dev-server.mjs.
|
||||
//
|
||||
// node build-scripts/build-manager.mjs [--modern] [mode]
|
||||
//
|
||||
// (no mode) Run in the foreground.
|
||||
// --background Start detached, print the pid, then exit and leave it
|
||||
// running.
|
||||
// --status Report whether a managed build is running.
|
||||
// --stop Stop a managed build.
|
||||
// --logs [--follow] Print (or follow) the background build log.
|
||||
//
|
||||
// --modern Build only the modern frontend_latest bundle.
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
LIFECYCLE_MODE_FLAGS,
|
||||
acquireProcessRecord,
|
||||
isProcessRecordAlive,
|
||||
outputLog,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
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 logFile = path.join(stateDir, "build.log");
|
||||
const lockFile = workflowLockFile;
|
||||
|
||||
const usage = () => {
|
||||
process.stderr.write(
|
||||
"Usage: node build-scripts/build-manager.mjs [--modern] " +
|
||||
"[--background | --status | --stop | --logs [--follow]]\n"
|
||||
);
|
||||
};
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const args = {
|
||||
mode: "foreground",
|
||||
modes: [],
|
||||
follow: false,
|
||||
modern: false,
|
||||
unknown: [],
|
||||
};
|
||||
for (const arg of argv) {
|
||||
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
|
||||
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
|
||||
args.modes.push(arg);
|
||||
} else if (arg === "--modern") {
|
||||
args.modern = true;
|
||||
} else if (arg === "--follow") {
|
||||
args.follow = true;
|
||||
} else {
|
||||
args.unknown.push(arg);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const hints = () =>
|
||||
" Stop: yarn build --stop\n" +
|
||||
" 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);
|
||||
|
||||
const acquireBuild = (modern, foreground) => {
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const result = acquireProcessRecord(lockFile, {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
processGroup: false,
|
||||
foreground,
|
||||
kind: "build",
|
||||
modern,
|
||||
starting: true,
|
||||
token,
|
||||
});
|
||||
return result.acquired ? { token } : { existing: result.existing };
|
||||
};
|
||||
|
||||
const updateBuild = (token, child, processGroup) => {
|
||||
const existing = readBuild();
|
||||
if (existing?.token !== token) {
|
||||
throw Error("Frontend build lock ownership was lost during startup.");
|
||||
}
|
||||
writeProcessRecord(lockFile, {
|
||||
...existing,
|
||||
pid: child.pid,
|
||||
startTime: processStartTime(child.pid),
|
||||
processGroup,
|
||||
starting: false,
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
process.stdout.write(
|
||||
`Dev server (${existing.suite}) already running` +
|
||||
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` +
|
||||
` Stop: yarn ${command} --stop\n` +
|
||||
` Status: yarn ${command} --status\n` +
|
||||
` Logs: yarn ${command} --logs\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`Frontend ${existing?.modern ? "modern " : ""}build already running` +
|
||||
`${existing?.pid ? ` (pid ${existing.pid})` : ""}.\n${hints()}`
|
||||
);
|
||||
};
|
||||
|
||||
const runForeground = async (modern) => {
|
||||
const lock = acquireBuild(modern, true);
|
||||
if (!lock.token) {
|
||||
reportExisting(lock.existing);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
return await spawnForeground({
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateBuild(lock.token, child, true),
|
||||
});
|
||||
} finally {
|
||||
releaseBuild(lock.token);
|
||||
}
|
||||
};
|
||||
|
||||
const runBackground = async (modern) => {
|
||||
const lock = acquireBuild(modern, false);
|
||||
if (!lock.token) {
|
||||
reportExisting(lock.existing);
|
||||
return 1;
|
||||
}
|
||||
let child;
|
||||
try {
|
||||
child = await spawnDetachedToLog({
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
updateBuild(lock.token, child, true);
|
||||
process.stdout.write(
|
||||
`Started ${modern ? "modern " : ""}frontend build (pid ${child.pid})\n` +
|
||||
hints()
|
||||
);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
if (child) {
|
||||
await terminateDetachedProcess(child);
|
||||
}
|
||||
releaseBuild(lock.token);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const runStatus = () => {
|
||||
const existing = readBuild();
|
||||
if (existing?.kind === "build" && isProcessRecordAlive(existing)) {
|
||||
process.stdout.write(
|
||||
`Frontend ${existing.modern ? "modern " : ""}build running (pid ${existing.pid}).\n`
|
||||
);
|
||||
} else {
|
||||
if (existing?.kind === "build") {
|
||||
releaseBuild(existing.token);
|
||||
}
|
||||
process.stdout.write("Frontend build not running.\n");
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runStop = async () => {
|
||||
let existing = readBuild();
|
||||
if (existing?.kind !== "build") {
|
||||
process.stdout.write("Frontend build not running.\n");
|
||||
return 0;
|
||||
}
|
||||
if (existing?.starting) {
|
||||
const token = existing.token;
|
||||
await waitFor(
|
||||
() => {
|
||||
const current = readBuild();
|
||||
return !current?.starting || current.token !== token;
|
||||
},
|
||||
100,
|
||||
5000
|
||||
);
|
||||
existing = readBuild();
|
||||
}
|
||||
if (
|
||||
!existing ||
|
||||
existing.kind !== "build" ||
|
||||
!isProcessRecordAlive(existing)
|
||||
) {
|
||||
if (existing) releaseBuild(existing.token);
|
||||
process.stdout.write("Frontend build not running.\n");
|
||||
return 0;
|
||||
}
|
||||
if (
|
||||
!(await terminateProcess({
|
||||
pid: existing.pid,
|
||||
processGroup: existing.processGroup,
|
||||
isStopped: () => !isProcessRecordAlive(existing),
|
||||
}))
|
||||
) {
|
||||
process.stderr.write(
|
||||
`Failed to stop frontend build (pid ${existing.pid}). Stop it manually.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
releaseBuild(existing.token);
|
||||
process.stdout.write(`Stopped frontend build (pid ${existing.pid}).\n`);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runLogs = (follow) =>
|
||||
outputLog(logFile, follow, `No frontend build log yet (${logFile}).\n`);
|
||||
|
||||
const main = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.unknown.length) {
|
||||
process.stderr.write(`Unknown arguments: ${args.unknown.join(" ")}\n`);
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
|
||||
process.stderr.write("Invalid combination of build arguments.\n");
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
const handlers = {
|
||||
foreground: () => runForeground(args.modern),
|
||||
background: () => runBackground(args.modern),
|
||||
status: runStatus,
|
||||
stop: runStop,
|
||||
logs: () => runLogs(args.follow),
|
||||
};
|
||||
return handlers[args.mode]();
|
||||
};
|
||||
|
||||
runCli(main);
|
||||
+364
-366
@@ -14,35 +14,16 @@
|
||||
//
|
||||
// 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 { spawn, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
LIFECYCLE_MODE_FLAGS,
|
||||
acquireProcessRecord,
|
||||
isProcessRecordAlive,
|
||||
outputLog,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
runCli,
|
||||
sleep,
|
||||
spawnDetachedToLog,
|
||||
spawnForeground,
|
||||
terminateDetachedProcess,
|
||||
terminateProcess,
|
||||
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,67 +35,49 @@ 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");
|
||||
|
||||
// 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
|
||||
// that means "ready" and, for app-serve, forward extra args to the script.
|
||||
const SUITES = new Map([
|
||||
[
|
||||
"e2e-app",
|
||||
{
|
||||
alias: "test:e2e:app:dev",
|
||||
liveness: "health",
|
||||
port: 8095,
|
||||
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
|
||||
},
|
||||
],
|
||||
[
|
||||
"demo",
|
||||
{
|
||||
alias: "dev:demo",
|
||||
liveness: "health",
|
||||
port: 8090,
|
||||
spawn: { cmd: gulpBin, args: ["develop-demo"] },
|
||||
},
|
||||
],
|
||||
[
|
||||
"gallery",
|
||||
{
|
||||
alias: "dev:gallery",
|
||||
liveness: "health",
|
||||
port: 8100,
|
||||
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
|
||||
},
|
||||
],
|
||||
[
|
||||
"app",
|
||||
{
|
||||
alias: "dev",
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
||||
},
|
||||
],
|
||||
[
|
||||
"app-serve",
|
||||
{
|
||||
alias: "dev:serve",
|
||||
liveness: "process",
|
||||
acceptsArgs: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
},
|
||||
],
|
||||
]);
|
||||
const SUITES = {
|
||||
"e2e-app": {
|
||||
alias: "test:e2e:app:dev",
|
||||
liveness: "health",
|
||||
port: 8095,
|
||||
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
|
||||
},
|
||||
demo: {
|
||||
alias: "dev:demo",
|
||||
liveness: "health",
|
||||
port: 8090,
|
||||
spawn: { cmd: gulpBin, args: ["develop-demo"] },
|
||||
},
|
||||
gallery: {
|
||||
alias: "dev:gallery",
|
||||
liveness: "health",
|
||||
port: 8100,
|
||||
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
|
||||
},
|
||||
app: {
|
||||
alias: "dev",
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
||||
},
|
||||
"app-serve": {
|
||||
alias: "dev:serve",
|
||||
liveness: "process",
|
||||
acceptsArgs: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
},
|
||||
};
|
||||
|
||||
// Cover a cold build on a slow machine before the server starts listening.
|
||||
// Override with HA_DEV_SERVER_TIMEOUT (seconds).
|
||||
const readyTimeoutSeconds = Number(process.env.HA_DEV_SERVER_TIMEOUT || "180");
|
||||
const READY_TIMEOUT_MS =
|
||||
Number.isFinite(readyTimeoutSeconds) && readyTimeoutSeconds > 0
|
||||
? readyTimeoutSeconds * 1000
|
||||
: 180_000;
|
||||
Number(process.env.HA_DEV_SERVER_TIMEOUT || "180") * 1000;
|
||||
|
||||
// Detect a coding agent from a small set of environment markers set by common
|
||||
// agent CLIs (env-only; no process-ancestry detection).
|
||||
@@ -141,7 +104,7 @@ const detectAgent = () => {
|
||||
};
|
||||
|
||||
const usage = () => {
|
||||
const suites = [...SUITES.keys()].join("|");
|
||||
const suites = Object.keys(SUITES).join("|");
|
||||
process.stderr.write(
|
||||
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
|
||||
`[--background | --status | --stop | --logs [--follow]]\n`
|
||||
@@ -152,7 +115,6 @@ const parseArgs = (argv) => {
|
||||
const args = {
|
||||
mode: "foreground",
|
||||
follow: false,
|
||||
modes: [],
|
||||
suite: undefined,
|
||||
passthrough: [],
|
||||
};
|
||||
@@ -162,68 +124,39 @@ const parseArgs = (argv) => {
|
||||
case "--suite":
|
||||
args.suite = argv[++i];
|
||||
break;
|
||||
case "--background":
|
||||
args.mode = "background";
|
||||
break;
|
||||
case "--status":
|
||||
args.mode = "status";
|
||||
break;
|
||||
case "--stop":
|
||||
args.mode = "stop";
|
||||
break;
|
||||
case "--logs":
|
||||
args.mode = "logs";
|
||||
break;
|
||||
case "--follow":
|
||||
args.follow = true;
|
||||
break;
|
||||
default:
|
||||
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
|
||||
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
|
||||
args.modes.push(arg);
|
||||
} else {
|
||||
// Anything unrecognised is forwarded to the underlying script.
|
||||
args.passthrough.push(arg);
|
||||
}
|
||||
// Anything unrecognised is forwarded to the underlying script.
|
||||
args.passthrough.push(arg);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
|
||||
const acquireSuite = (suite) => {
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const record = {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
kind: "dev",
|
||||
suite,
|
||||
starting: true,
|
||||
token,
|
||||
};
|
||||
const result = acquireProcessRecord(workflowLockFile, record);
|
||||
return result.acquired ? { token } : { existing: result.existing };
|
||||
};
|
||||
|
||||
const updateSuite = (suite, token, child, port) => {
|
||||
const existing = readProcessRecord(workflowLockFile);
|
||||
if (existing?.token !== token) {
|
||||
throw Error(`Dev server (${suite}) ownership was lost during startup.`);
|
||||
}
|
||||
writeProcessRecord(workflowLockFile, {
|
||||
...existing,
|
||||
pid: child.pid,
|
||||
startTime: processStartTime(child.pid),
|
||||
processGroup: true,
|
||||
starting: false,
|
||||
port,
|
||||
const sleep = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
};
|
||||
|
||||
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 logFileFor = (suite) => path.join(logDir, `${suite}.log`);
|
||||
const pidFileFor = (suite) => path.join(logDir, `${suite}.pid`);
|
||||
|
||||
const hints = (suite) => {
|
||||
const alias = `yarn ${SUITES.get(suite).alias}`;
|
||||
const alias = `yarn ${SUITES[suite].alias}`;
|
||||
return (
|
||||
` Stop: ${alias} --stop\n` +
|
||||
` Status: ${alias} --status\n` +
|
||||
@@ -231,49 +164,46 @@ 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})` : ""}. ` +
|
||||
"Stop it with yarn build --stop.\n"
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`Dev server (${existing?.suite ?? suite}) already running` +
|
||||
`${urlSuffix(existing?.port)} ` +
|
||||
`${existing?.pid ? `(pid ${existing.pid})` : ""}\n` +
|
||||
hints(existing?.suite ?? suite)
|
||||
);
|
||||
};
|
||||
|
||||
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 ------------------------------------------
|
||||
|
||||
// Signal the whole process group (the background server is its group leader),
|
||||
// falling back to the bare pid if that is not permitted.
|
||||
const killProcessTree = (pid, sig) => {
|
||||
try {
|
||||
process.kill(-pid, sig);
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, sig);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
|
||||
|
||||
// Run a server in the foreground, inheriting stdio; resolve with its exit code.
|
||||
const spawnInherit = (cmd, args) =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, { cwd: repoRoot, stdio: "inherit" });
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
|
||||
// Spawn a detached server that writes stdout and stderr to the suite's log file.
|
||||
const spawnDetachedToLog = (suite, cmd, args) => {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logFile = logFileFor(suite);
|
||||
const fd = fs.openSync(logFile, "w");
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: repoRoot,
|
||||
detached: true,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
fs.closeSync(fd);
|
||||
child.unref();
|
||||
return { child, logFile };
|
||||
};
|
||||
|
||||
// Poll until the server is ready, the child exits, or we time out. Prints the
|
||||
// progress dots and outcome; returns 0 when ready, 1 otherwise. onExit runs if
|
||||
// the child dies before it is ready (used to clear a stale pidfile).
|
||||
@@ -284,7 +214,8 @@ const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
|
||||
});
|
||||
const deadline = Date.now() + READY_TIMEOUT_MS;
|
||||
process.stdout.write(`Starting ${suite} dev server`);
|
||||
const poll = async () => {
|
||||
/* eslint-disable no-await-in-loop -- poll until the server is ready */
|
||||
while (Date.now() < deadline) {
|
||||
if (childExited) {
|
||||
process.stdout.write("\n");
|
||||
process.stderr.write(
|
||||
@@ -301,37 +232,38 @@ const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return undefined;
|
||||
}
|
||||
process.stdout.write(".");
|
||||
await sleep(1000);
|
||||
return poll();
|
||||
};
|
||||
const result = await poll();
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
process.stdout.write("\n");
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) did not become ready within ${
|
||||
READY_TIMEOUT_MS / 1000
|
||||
}s. See ${logFile}\n`
|
||||
);
|
||||
const stopped = await terminateProcess({
|
||||
pid: child.pid,
|
||||
isStopped: () => childExited,
|
||||
});
|
||||
if (stopped) {
|
||||
onExit?.();
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
// Stop a running background server: SIGTERM, wait for it to go, then SIGKILL.
|
||||
// isStopped reports when it is gone; onStopped runs on success (pidfile cleanup).
|
||||
const terminate = async (suite, pid, isStopped, onStopped) => {
|
||||
if (!(await terminateProcess({ pid, isStopped }))) {
|
||||
killProcessTree(pid, "SIGTERM");
|
||||
const deadline = Date.now() + 10_000;
|
||||
/* eslint-disable no-await-in-loop -- poll until the server is gone */
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(300);
|
||||
if (await isStopped()) {
|
||||
onStopped?.();
|
||||
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
// Escalate if it is still up.
|
||||
killProcessTree(pid, "SIGKILL");
|
||||
await sleep(300);
|
||||
if (!(await isStopped())) {
|
||||
process.stderr.write(
|
||||
`Failed to stop dev server (${suite}) (pid ${pid}). Stop it manually.\n`
|
||||
);
|
||||
@@ -352,11 +284,9 @@ const terminate = async (suite, pid, isStopped, onStopped) => {
|
||||
const PROBE_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
||||
|
||||
const probe = async (port, timeoutMs = 1000) => {
|
||||
const probeHost = async (index, sawResponse) => {
|
||||
const host = PROBE_HOSTS[index];
|
||||
if (!host) {
|
||||
return sawResponse ? { state: "foreign" } : { state: "free" };
|
||||
}
|
||||
let sawResponse = false;
|
||||
/* eslint-disable no-await-in-loop -- probe localhost addresses in order, stopping at the first that answers */
|
||||
for (const host of PROBE_HOSTS) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
@@ -375,120 +305,179 @@ const probe = async (port, timeoutMs = 1000) => {
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return probeHost(index + 1, sawResponse);
|
||||
};
|
||||
return probeHost(0, false);
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
return sawResponse ? { state: "foreign" } : { state: "free" };
|
||||
};
|
||||
|
||||
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") {
|
||||
releaseSuite(lock.token);
|
||||
process.stderr.write(
|
||||
`Port ${port} is already serving the ${status.suite ?? "unknown"} dev server.\n`
|
||||
if (status.state === "ours" && status.suite === suite) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) is already running at http://localhost:${port}\n`
|
||||
);
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
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 spawnInherit(cfg.spawn.cmd, cfg.spawn.args);
|
||||
};
|
||||
|
||||
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") {
|
||||
releaseSuite(lock.token);
|
||||
process.stderr.write(
|
||||
`Port ${port} is already serving the ${preflight.suite ?? "unknown"} dev server.\n`
|
||||
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 1;
|
||||
return 0;
|
||||
}
|
||||
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;
|
||||
|
||||
const { child, logFile } = spawnDetachedToLog(
|
||||
suite,
|
||||
cfg.spawn.cmd,
|
||||
cfg.spawn.args
|
||||
);
|
||||
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 isAlive = (pid) => {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
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),
|
||||
});
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (child) {
|
||||
await terminateDetachedProcess(child);
|
||||
// EPERM means the process exists but is owned by someone else.
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
const readPidFile = (suite) => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(pidFileFor(suite), "utf8"));
|
||||
if (data && Number.isInteger(data.pid)) {
|
||||
return data;
|
||||
}
|
||||
releaseSuite(lock.token);
|
||||
throw err;
|
||||
} catch {
|
||||
// Missing or corrupt.
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const writePidFile = (suite, data) => {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(pidFileFor(suite), JSON.stringify(data));
|
||||
};
|
||||
|
||||
const removePidFile = (suite) => {
|
||||
try {
|
||||
fs.rmSync(pidFileFor(suite));
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -503,13 +492,11 @@ const logIsReady = (logFile, readyLog) => {
|
||||
// app-serve serves on 8124 by default (8123 in a devcontainer), or whatever -p
|
||||
// the caller passed. Used only to show a URL; liveness comes from the pidfile.
|
||||
const resolveServePort = (passthrough) => {
|
||||
for (let i = passthrough.length - 1; i >= 0; i--) {
|
||||
const arg = passthrough[i];
|
||||
if (arg === "-p" || arg.startsWith("-p")) {
|
||||
const port = Number(arg === "-p" ? passthrough[i + 1] : arg.slice(2));
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
return port;
|
||||
}
|
||||
const i = passthrough.indexOf("-p");
|
||||
if (i !== -1) {
|
||||
const port = Number(passthrough[i + 1]);
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return process.env.DEVCONTAINER ? 8123 : 8124;
|
||||
@@ -521,115 +508,119 @@ const spawnArgs = (cfg, passthrough) => [
|
||||
];
|
||||
|
||||
const runForegroundProcess = async (suite, cfg, passthrough) => {
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
return lock.code;
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running in the background ` +
|
||||
`(pid ${existing.pid}). Stop it with yarn ${cfg.alias} --stop.\n`
|
||||
);
|
||||
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),
|
||||
});
|
||||
} finally {
|
||||
releaseSuite(lock.token);
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
return spawnInherit(cfg.spawn.cmd, spawnArgs(cfg, passthrough));
|
||||
};
|
||||
|
||||
const runBackgroundProcess = async (suite, cfg, passthrough) => {
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
return lock.code;
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running${urlSuffix(existing.port)} ` +
|
||||
`(pid ${existing.pid})\n${hints(suite)}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
|
||||
let child;
|
||||
try {
|
||||
const logFile = logFileFor(suite);
|
||||
child = await spawnDetachedToLog({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: spawnArgs(cfg, passthrough),
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
const { child, logFile } = spawnDetachedToLog(
|
||||
suite,
|
||||
cfg.spawn.cmd,
|
||||
spawnArgs(cfg, passthrough)
|
||||
);
|
||||
|
||||
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
|
||||
updateSuite(suite, lock.token, child, port);
|
||||
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
|
||||
writePidFile(suite, { pid: child.pid, port });
|
||||
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: async () =>
|
||||
logIsReady(logFile, cfg.readyLog) &&
|
||||
(!cfg.acceptsArgs || (await isHttpServing(port))),
|
||||
onExit: () => releaseSuite(lock.token),
|
||||
});
|
||||
} catch (err) {
|
||||
if (child) {
|
||||
await terminateDetachedProcess(child);
|
||||
}
|
||||
releaseSuite(lock.token);
|
||||
throw err;
|
||||
}
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: () => logIsReady(logFile, cfg.readyLog),
|
||||
onExit: () => removePidFile(suite),
|
||||
});
|
||||
};
|
||||
|
||||
const runStatusSuite = async (suite, cfg) => {
|
||||
const existing = readSuite(suite);
|
||||
if (existing) {
|
||||
const runStatusProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${existing.suite ?? suite}) running${urlSuffix(existing.port ?? cfg.port)} ` +
|
||||
`Dev server (${suite}) running${urlSuffix(existing.port)} ` +
|
||||
`(pid ${existing.pid})\n`
|
||||
);
|
||||
} else {
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
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) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (!existing || !isAlive(existing.pid)) {
|
||||
// Idempotent: stopping something that is not running is a success.
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
return 0;
|
||||
}
|
||||
const { pid } = existing;
|
||||
const activeSuite = existing.suite ?? suite;
|
||||
return terminate(
|
||||
activeSuite,
|
||||
suite,
|
||||
pid,
|
||||
() => !isProcessRecordAlive(existing),
|
||||
() => releaseSuite(existing.token)
|
||||
() => !isAlive(pid),
|
||||
() => removePidFile(suite)
|
||||
);
|
||||
};
|
||||
|
||||
// --- shared -----------------------------------------------------------------
|
||||
|
||||
const runLogs = (suite, follow) => {
|
||||
const activeSuite = readSuite(suite)?.suite ?? suite;
|
||||
return outputLog(
|
||||
logFileFor(activeSuite),
|
||||
follow,
|
||||
`No log for the ${activeSuite} dev server yet (${logFileFor(activeSuite)}).\n`
|
||||
);
|
||||
const logFile = logFileFor(suite);
|
||||
if (!fs.existsSync(logFile)) {
|
||||
process.stdout.write(
|
||||
`No log for the ${suite} dev server yet (${logFile}).\n`
|
||||
);
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
if (!follow) {
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
|
||||
tail.on("error", () => {
|
||||
// No tail available; fall back to a one-shot dump.
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
resolve(0);
|
||||
});
|
||||
tail.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cfg = SUITES.get(args.suite);
|
||||
const cfg = SUITES[args.suite];
|
||||
if (!cfg) {
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
|
||||
process.stderr.write("Invalid combination of lifecycle arguments.\n");
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.passthrough.length && !cfg.acceptsArgs) {
|
||||
process.stderr.write(
|
||||
`Ignoring unexpected arguments: ${args.passthrough.join(" ")}\n`
|
||||
@@ -653,28 +644,35 @@ const main = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "logs") {
|
||||
return runLogs(args.suite, args.follow);
|
||||
const health = cfg.liveness === "health";
|
||||
switch (mode) {
|
||||
case "background":
|
||||
return health
|
||||
? runBackgroundHealth(args.suite, cfg)
|
||||
: runBackgroundProcess(args.suite, cfg, args.passthrough);
|
||||
case "status":
|
||||
return health
|
||||
? runStatusHealth(args.suite, cfg)
|
||||
: runStatusProcess(args.suite);
|
||||
case "stop":
|
||||
return health
|
||||
? runStopHealth(args.suite, cfg)
|
||||
: runStopProcess(args.suite);
|
||||
case "logs":
|
||||
return runLogs(args.suite, args.follow);
|
||||
default:
|
||||
return health
|
||||
? runForegroundHealth(args.suite, cfg)
|
||||
: runForegroundProcess(args.suite, cfg, args.passthrough);
|
||||
}
|
||||
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),
|
||||
}
|
||||
: {
|
||||
foreground: () =>
|
||||
runForegroundProcess(args.suite, cfg, args.passthrough),
|
||||
background: () =>
|
||||
runBackgroundProcess(args.suite, cfg, args.passthrough),
|
||||
};
|
||||
return handlers[mode]();
|
||||
};
|
||||
|
||||
runCli(main);
|
||||
main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
process.stderr.write(`${err?.stack || err}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
@@ -54,30 +51,6 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-app-modern",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-app-modern"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"build-translations",
|
||||
"build-locale-data",
|
||||
"gen-licenses"
|
||||
),
|
||||
"copy-static-app",
|
||||
"rspack-prod-app-modern",
|
||||
gulp.parallel(
|
||||
"gen-pages-app-prod-modern",
|
||||
"gen-service-worker-app-prod-modern"
|
||||
),
|
||||
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"analyze-app",
|
||||
gulp.series(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -147,11 +147,9 @@ const genPagesProdTask =
|
||||
{
|
||||
...commonVars,
|
||||
latestEntryJS: entries.map((entry) => latestManifest[`${entry}.js`]),
|
||||
es5EntryJS: outputES5
|
||||
? entries.map((entry) => es5Manifest[`${entry}.js`])
|
||||
: [],
|
||||
es5EntryJS: entries.map((entry) => es5Manifest[`${entry}.js`]),
|
||||
latestCustomPanelJS: latestManifest["custom-panel.js"],
|
||||
es5CustomPanelJS: outputES5 ? es5Manifest["custom-panel.js"] : "",
|
||||
es5CustomPanelJS: es5Manifest["custom-panel.js"],
|
||||
}
|
||||
);
|
||||
minifiedHTML.push(
|
||||
@@ -186,17 +184,6 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-app-prod-modern",
|
||||
genPagesProdTask(
|
||||
APP_PAGE_ENTRIES,
|
||||
paths.root_dir,
|
||||
paths.app_output_root,
|
||||
paths.app_output_latest,
|
||||
undefined
|
||||
)
|
||||
);
|
||||
|
||||
const CAST_PAGE_ENTRIES = {
|
||||
"faq.html": ["launcher"],
|
||||
"index.html": ["launcher"],
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -168,17 +160,6 @@ gulp.task("rspack-prod-app", () =>
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-app-modern", () =>
|
||||
prodBuild(
|
||||
createAppConfig({
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
latestBuild: true,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-dev-server-demo", () =>
|
||||
runDevServer({
|
||||
compiler: rspack(
|
||||
|
||||
@@ -34,9 +34,9 @@ gulp.task("gen-service-worker-app-dev", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
const genServiceWorker = (builds) =>
|
||||
gulp.task("gen-service-worker-app-prod", () =>
|
||||
Promise.all(
|
||||
builds.map(async ([outPath, build]) => {
|
||||
Object.entries(SW_MAP).map(async ([outPath, build]) => {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(join(outPath, "manifest.json"), "utf-8")
|
||||
);
|
||||
@@ -83,12 +83,5 @@ const genServiceWorker = (builds) =>
|
||||
await symlink(basename(swDest), swOld);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
gulp.task("gen-service-worker-app-prod", () =>
|
||||
genServiceWorker(Object.entries(SW_MAP))
|
||||
);
|
||||
|
||||
gulp.task("gen-service-worker-app-prod-modern", () =>
|
||||
genServiceWorker([[paths.app_output_latest, "modern"]])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import { deleteAsync } from "del";
|
||||
import { glob } from "glob";
|
||||
import gulp from "gulp";
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const LIFECYCLE_MODE_FLAGS = new Map([
|
||||
["--background", "background"],
|
||||
["--status", "status"],
|
||||
["--stop", "stop"],
|
||||
["--logs", "logs"],
|
||||
]);
|
||||
|
||||
export const sleep = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
export const waitFor = async (predicate, intervalMs, timeoutMs) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const poll = async () => {
|
||||
if (await predicate()) {
|
||||
return true;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return false;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
return poll();
|
||||
};
|
||||
return poll();
|
||||
};
|
||||
|
||||
export const isProcessAlive = (pid) => {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// EPERM means the process exists but is owned by someone else.
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
export const processStartTime = (pid) => {
|
||||
if (!isProcessAlive(pid)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
|
||||
} catch {
|
||||
try {
|
||||
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const isProcessRecordAlive = ({ pid, startTime }) =>
|
||||
Boolean(startTime) && processStartTime(pid) === startTime;
|
||||
|
||||
export const readProcessRecord = (file) => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
return data && Number.isInteger(data.pid) ? data : undefined;
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT" || err instanceof SyntaxError) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeProcessRecord = (file, data) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temporary, JSON.stringify(data));
|
||||
fs.renameSync(temporary, file);
|
||||
} finally {
|
||||
removeFileIfExists(temporary);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeProcessRecord = (file) => {
|
||||
removeFileIfExists(file);
|
||||
};
|
||||
|
||||
export const acquireProcessRecord = (file, data) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const fd = fs.openSync(file, "wx");
|
||||
try {
|
||||
fs.writeFileSync(fd, JSON.stringify(data));
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return { acquired: true };
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
const existing = readProcessRecord(file);
|
||||
if (existing && isProcessRecordAlive(existing)) {
|
||||
return { acquired: false, existing };
|
||||
}
|
||||
if (!existing && isRecentFile(file)) {
|
||||
return { acquired: false };
|
||||
}
|
||||
const removed = withExclusiveFileLockSync(`${file}.cleanup`, () => {
|
||||
const current = readProcessRecord(file);
|
||||
if (
|
||||
current &&
|
||||
(current.token !== existing?.token || isProcessRecordAlive(current))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
removeProcessRecord(file);
|
||||
return true;
|
||||
});
|
||||
if (!removed.acquired || !removed.value) {
|
||||
return { acquired: false, existing: readProcessRecord(file) };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { acquired: false, existing: readProcessRecord(file) };
|
||||
};
|
||||
|
||||
export const releaseProcessRecord = (file, token, onRelease) => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
withExclusiveFileLockSync(`${file}.cleanup`, () => {
|
||||
const existing = readProcessRecord(file);
|
||||
if (!existing || existing.token === token) {
|
||||
onRelease?.();
|
||||
if (existing) {
|
||||
removeProcessRecord(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const removeFileIfExists = (file) => {
|
||||
try {
|
||||
fs.rmSync(file);
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isRecentFile = (file) => {
|
||||
try {
|
||||
return Date.now() - fs.statSync(file).mtimeMs < 5000;
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const withExclusiveFileLockSync = (file, operation) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(file, "wx");
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
const owner = readProcessRecord(file);
|
||||
if (owner && isProcessRecordAlive(owner)) {
|
||||
return { acquired: false };
|
||||
}
|
||||
if (!owner && isRecentFile(file)) {
|
||||
return { acquired: false };
|
||||
}
|
||||
removeFileIfExists(file);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
fd,
|
||||
JSON.stringify({
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
})
|
||||
);
|
||||
return { acquired: true, value: operation() };
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} finally {
|
||||
removeFileIfExists(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { acquired: false };
|
||||
};
|
||||
|
||||
export const spawnForeground = ({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
processGroup = false,
|
||||
onSpawn,
|
||||
}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: processGroup,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
let settled = false;
|
||||
const forwardSigint = () =>
|
||||
signalProcess(child.pid, "SIGINT", processGroup);
|
||||
const forwardSigterm = () =>
|
||||
signalProcess(child.pid, "SIGTERM", processGroup);
|
||||
const forwardSighup = () =>
|
||||
signalProcess(child.pid, "SIGHUP", processGroup);
|
||||
const removeSignalHandlers = () => {
|
||||
process.off("SIGINT", forwardSigint);
|
||||
process.off("SIGTERM", forwardSigterm);
|
||||
process.off("SIGHUP", forwardSighup);
|
||||
};
|
||||
if (processGroup) {
|
||||
process.on("SIGINT", forwardSigint);
|
||||
process.on("SIGTERM", forwardSigterm);
|
||||
process.on("SIGHUP", forwardSighup);
|
||||
}
|
||||
child.once("spawn", () => {
|
||||
try {
|
||||
onSpawn?.(child);
|
||||
} catch (err) {
|
||||
settled = true;
|
||||
signalProcess(child.pid, "SIGTERM", processGroup);
|
||||
removeSignalHandlers();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
child.once("error", (err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
removeSignalHandlers();
|
||||
process.stderr.write(`Failed to start ${cmd}: ${err.message}\n`);
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
removeSignalHandlers();
|
||||
resolve(code ?? 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
||||
const fd = fs.openSync(logFile, "w");
|
||||
let child;
|
||||
try {
|
||||
child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: true,
|
||||
env,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
child.once("spawn", () => {
|
||||
child.unref();
|
||||
resolve(child);
|
||||
});
|
||||
child.once("error", reject);
|
||||
});
|
||||
|
||||
const signalProcess = (pid, signal, processGroup) => {
|
||||
if (processGroup) {
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to the process itself.
|
||||
}
|
||||
}
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
};
|
||||
|
||||
export const terminateProcess = async ({
|
||||
pid,
|
||||
isStopped,
|
||||
processGroup = true,
|
||||
graceMs = 10_000,
|
||||
}) => {
|
||||
signalProcess(pid, "SIGTERM", processGroup);
|
||||
if (await waitFor(isStopped, 300, graceMs)) {
|
||||
return true;
|
||||
}
|
||||
signalProcess(pid, "SIGKILL", processGroup);
|
||||
await sleep(300);
|
||||
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);
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
if (!follow) {
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
|
||||
let settled = false;
|
||||
tail.once("error", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
tail.once("exit", (code) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(code ?? 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const runCli = (main) => {
|
||||
main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
process.stderr.write(`${err?.stack || err}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -23,7 +23,7 @@ export class DemoHaProgressButton extends LitElement {
|
||||
<ha-progress-button @click=${this._clickedFail}>
|
||||
Fail
|
||||
</ha-progress-button>
|
||||
<ha-progress-button size="s" @click=${this._clickedSuccess}>
|
||||
<ha-progress-button size="small" @click=${this._clickedSuccess}>
|
||||
small
|
||||
</ha-progress-button>
|
||||
<ha-progress-button
|
||||
|
||||
+7
-7
@@ -7,7 +7,7 @@
|
||||
"name": "home-assistant-frontend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"build": "node build-scripts/build-manager.mjs",
|
||||
"build": "script/build_frontend",
|
||||
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
|
||||
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.7",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.5.2",
|
||||
@@ -152,7 +152,7 @@
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.6",
|
||||
"@rspack/core": "2.1.5",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -186,14 +186,14 @@
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.8.0",
|
||||
"globals": "17.7.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.0.0",
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.2.0",
|
||||
@@ -224,12 +224,12 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.8.0",
|
||||
"globals": "17.7.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.17.1",
|
||||
"volta": {
|
||||
"node": "24.18.1"
|
||||
"node": "24.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260729.0"
|
||||
version = "20260729.3"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,8 +18,6 @@ export class HaProgressButton extends LitElement {
|
||||
|
||||
@property() appearance: Appearance = "accent";
|
||||
|
||||
@property() size: "xs" | "s" | "m" | "l" | "xl" = "m";
|
||||
|
||||
@property({ attribute: false }) public iconPath?: string;
|
||||
|
||||
@property() variant: "brand" | "danger" | "neutral" | "warning" | "success" =
|
||||
@@ -34,7 +32,6 @@ export class HaProgressButton extends LitElement {
|
||||
return html`
|
||||
<ha-button
|
||||
.appearance=${appearance}
|
||||
.size=${this.size}
|
||||
.disabled=${this.disabled}
|
||||
.loading=${this.progress}
|
||||
.variant=${
|
||||
@@ -121,12 +118,6 @@ export class HaProgressButton extends LitElement {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The icon lives in this shadow root, so callers cannot size it themselves. */
|
||||
ha-button[size="xs"] ha-svg-icon[slot="start"],
|
||||
ha-button[size="s"] ha-svg-icon[slot="start"] {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
|
||||
ha-button.result::part(start),
|
||||
ha-button.result::part(end),
|
||||
ha-button.result::part(label),
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { join } from "lit/directives/join";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
|
||||
@@ -484,7 +483,13 @@ export class HaDataTable extends LitElement {
|
||||
: ""
|
||||
}
|
||||
${Object.entries(columns).map(([key, column]) => {
|
||||
if (!this._isColumnVisible(key, column)) {
|
||||
if (
|
||||
column.hidden ||
|
||||
(this.columnOrder && this.columnOrder.includes(key)
|
||||
? (this.hiddenColumns?.includes(key) ??
|
||||
column.defaultHidden)
|
||||
: column.defaultHidden)
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
const sorted = key === this.sortColumn;
|
||||
@@ -652,7 +657,10 @@ export class HaDataTable extends LitElement {
|
||||
${Object.entries(columns).map(([key, column]) => {
|
||||
if (
|
||||
(narrow && !column.main && !column.showNarrow) ||
|
||||
!this._isColumnVisible(key, column)
|
||||
column.hidden ||
|
||||
(this.columnOrder && this.columnOrder.includes(key)
|
||||
? (this.hiddenColumns?.includes(key) ?? column.defaultHidden)
|
||||
: column.defaultHidden)
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
@@ -684,19 +692,28 @@ export class HaDataTable extends LitElement {
|
||||
: narrow && column.main
|
||||
? html`<div class="primary">${row[key]}</div>
|
||||
<div class="secondary">
|
||||
${join(
|
||||
Object.entries(columns)
|
||||
.filter(([key2, column2]) =>
|
||||
this._isSecondaryColumnVisible(key2, column2)
|
||||
)
|
||||
.map(([key2, column2]) =>
|
||||
column2.template
|
||||
? column2.template(row)
|
||||
: row[key2]
|
||||
)
|
||||
.filter(this._hasCellValue),
|
||||
STRINGS_SEPARATOR_DOT
|
||||
)}
|
||||
${Object.entries(columns)
|
||||
.filter(
|
||||
([key2, column2]) =>
|
||||
!column2.hidden &&
|
||||
!column2.main &&
|
||||
!column2.showNarrow &&
|
||||
!(this.columnOrder &&
|
||||
this.columnOrder.includes(key2)
|
||||
? (this.hiddenColumns?.includes(key2) ??
|
||||
column2.defaultHidden)
|
||||
: column2.defaultHidden)
|
||||
)
|
||||
.map(
|
||||
([key2, column2], i) =>
|
||||
html`${
|
||||
i !== 0 ? STRINGS_SEPARATOR_DOT : nothing
|
||||
}${
|
||||
column2.template
|
||||
? column2.template(row)
|
||||
: row[key2]
|
||||
}`
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
column.extraTemplate
|
||||
@@ -716,29 +733,6 @@ export class HaDataTable extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _isColumnVisible(key: string, column: DataTableColumnData): boolean {
|
||||
if (column.hidden) {
|
||||
return false;
|
||||
}
|
||||
if (!this.columnOrder?.includes(key)) {
|
||||
return !column.defaultHidden;
|
||||
}
|
||||
return !(this.hiddenColumns?.includes(key) ?? column.defaultHidden);
|
||||
}
|
||||
|
||||
private _isSecondaryColumnVisible(
|
||||
key: string,
|
||||
column: DataTableColumnData
|
||||
): boolean {
|
||||
if (column.main || column.showNarrow) {
|
||||
return false;
|
||||
}
|
||||
return this._isColumnVisible(key, column);
|
||||
}
|
||||
|
||||
private _hasCellValue = (value: unknown): boolean =>
|
||||
value !== undefined && value !== null && value !== "" && value !== nothing;
|
||||
|
||||
private async _sortFilterData() {
|
||||
const startTime = new Date().getTime();
|
||||
const timeBetweenUpdate = startTime - this._lastUpdate;
|
||||
|
||||
@@ -314,11 +314,6 @@ export interface ZWaveJSSetConfigParamResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ZwaveJSNodeConfigParameterUpdate {
|
||||
id: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export interface ZWaveJSDataCollectionStatus {
|
||||
enabled: boolean;
|
||||
opted_in: boolean;
|
||||
@@ -737,16 +732,6 @@ export const fetchZwaveNodeConfigParameters = (
|
||||
device_id,
|
||||
});
|
||||
|
||||
export const subscribeZwaveNodeConfigParameterUpdates = (
|
||||
hass: HomeAssistant,
|
||||
device_id: string,
|
||||
callback: (update: ZwaveJSNodeConfigParameterUpdate) => void
|
||||
): Promise<UnsubscribeFunc> =>
|
||||
hass.connection.subscribeMessage(callback, {
|
||||
type: "zwave_js/subscribe_config_parameter_updates",
|
||||
device_id,
|
||||
});
|
||||
|
||||
export const setZwaveNodeConfigParameter = (
|
||||
hass: HomeAssistant,
|
||||
device_id: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
@@ -12,13 +12,11 @@ import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
|
||||
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
|
||||
import type { HaForm } from "../../components/ha-form/ha-form";
|
||||
|
||||
interface StackEntry {
|
||||
params: FormDialogParams;
|
||||
data: FormDialogData;
|
||||
nestedField?: string;
|
||||
error?: Record<string, string>;
|
||||
}
|
||||
|
||||
@customElement("dialog-form")
|
||||
@@ -38,15 +36,10 @@ export class DialogForm
|
||||
|
||||
@state() private _stack: StackEntry[] = [];
|
||||
|
||||
@state() private _error?: Record<string, string>;
|
||||
|
||||
@query("ha-form") private _form?: HaForm;
|
||||
|
||||
public async showDialog(params: FormDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._data = params.data || {};
|
||||
this._open = true;
|
||||
this._error = undefined;
|
||||
this._initDirtyTracking({ type: "deep" }, this._data);
|
||||
}
|
||||
|
||||
@@ -66,17 +59,11 @@ export class DialogForm
|
||||
const origin = ev.composedPath()[0] as HTMLElement & { name?: string };
|
||||
this._stack = [
|
||||
...this._stack,
|
||||
{
|
||||
params: this._params!,
|
||||
data: this._data,
|
||||
nestedField: origin?.name,
|
||||
error: this._error,
|
||||
},
|
||||
{ params: this._params!, data: this._data, nestedField: origin?.name },
|
||||
];
|
||||
const nested = ev.detail.dialogParams as FormDialogParams;
|
||||
this._params = nested;
|
||||
this._data = nested?.data || {};
|
||||
this._error = undefined;
|
||||
this._initDirtyTracking({ type: "deep" }, this._data);
|
||||
};
|
||||
|
||||
@@ -88,7 +75,6 @@ export class DialogForm
|
||||
this._stack = this._stack.slice(0, -1);
|
||||
this._params = prev.params;
|
||||
this._data = prev.data;
|
||||
this._error = prev.error;
|
||||
this._initDirtyTracking({ type: "deep" }, this._data);
|
||||
return prev.nestedField;
|
||||
}
|
||||
@@ -102,18 +88,10 @@ export class DialogForm
|
||||
this._params = undefined;
|
||||
this._data = {};
|
||||
this._open = false;
|
||||
this._error = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private _submit(): void {
|
||||
if (this._form && !this._form.reportValidity()) {
|
||||
this._error = {
|
||||
base: this.hass!.localize("ui.components.form.validation_failed"),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
this._closeState = "submitted";
|
||||
const submit = this._params?.submit;
|
||||
const data = this._data;
|
||||
@@ -141,7 +119,6 @@ export class DialogForm
|
||||
: data;
|
||||
|
||||
this._data = deepClone({ ...this._data, [nestedField]: newValue });
|
||||
this._error = undefined;
|
||||
this._updateDirtyState(this._data);
|
||||
}
|
||||
|
||||
@@ -159,7 +136,6 @@ export class DialogForm
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
this._data = ev.detail.value;
|
||||
this._error = undefined;
|
||||
this._updateDirtyState(this._data);
|
||||
}
|
||||
|
||||
@@ -182,7 +158,6 @@ export class DialogForm
|
||||
.computeHelper=${this._params.computeHelper}
|
||||
.data=${this._data}
|
||||
.schema=${this._params.schema}
|
||||
.error=${this._error}
|
||||
@value-changed=${this._valueChanged}
|
||||
@show-dialog=${this._handleNestedShowDialog}
|
||||
>
|
||||
|
||||
@@ -346,19 +346,17 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
maxWidth: "82px",
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
hidden: narrow,
|
||||
type: "overflow",
|
||||
title: this.hass.localize("ui.panel.config.automation.picker.state"),
|
||||
template: (automation) =>
|
||||
narrow
|
||||
? automation.formatted_state
|
||||
: html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
template: (automation) => html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
|
||||
@@ -203,10 +203,12 @@ export function getModifiedAtTableColumn<T>(
|
||||
}
|
||||
|
||||
const renderDateTimeColumn = (valueDateTime: number, hass: HomeAssistant) =>
|
||||
valueDateTime
|
||||
? formatShortDateTimeWithConditionalYear(
|
||||
new Date(valueDateTime * 1000),
|
||||
hass.locale,
|
||||
hass.config
|
||||
)
|
||||
: nothing;
|
||||
html`${
|
||||
valueDateTime
|
||||
? formatShortDateTimeWithConditionalYear(
|
||||
new Date(valueDateTime * 1000),
|
||||
hass.locale,
|
||||
hass.config
|
||||
)
|
||||
: nothing
|
||||
}`;
|
||||
|
||||
@@ -1395,7 +1395,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 40px;
|
||||
text-align: start;
|
||||
text-align: left;
|
||||
text-underline-position: from-font;
|
||||
text-decoration-skip-ink: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -19,8 +19,6 @@ import { animationStyles } from "../../../../../resources/theme/animations.globa
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/buttons/ha-progress-button";
|
||||
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
|
||||
|
||||
import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/ha-md-list";
|
||||
@@ -69,8 +67,6 @@ class ZHAConfigDashboard extends LitElement {
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _generatingBackup = false;
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (!this.hass) {
|
||||
@@ -359,18 +355,17 @@ class ZHAConfigDashboard extends LitElement {
|
||||
"ui.panel.config.zha.configuration_page.download_backup_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-progress-button
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
size="s"
|
||||
.iconPath=${mdiDownload}
|
||||
.progress=${this._generatingBackup}
|
||||
@click=${this._createAndDownloadBackup}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.download_backup_action"
|
||||
)}
|
||||
</ha-progress-button>
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
@@ -413,29 +408,30 @@ class ZHAConfigDashboard extends LitElement {
|
||||
this._configuration = await fetchZHAConfiguration(this.hass!);
|
||||
}
|
||||
|
||||
private async _createAndDownloadBackup(ev: Event): Promise<void> {
|
||||
const button = ev.currentTarget as HaProgressButton;
|
||||
private async _createAndDownloadBackup(): Promise<void> {
|
||||
let backup_and_metadata: ZHANetworkBackupAndMetadata;
|
||||
|
||||
// Reading the backup from the coordinator can take 5-30 seconds.
|
||||
this._generatingBackup = true;
|
||||
|
||||
try {
|
||||
backup_and_metadata = await createZHANetworkBackup(this.hass!);
|
||||
} catch (err: any) {
|
||||
button.actionError();
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.backup_failed"
|
||||
),
|
||||
title: "Failed to create backup",
|
||||
text: err.message,
|
||||
warning: true,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
this._generatingBackup = false;
|
||||
}
|
||||
|
||||
if (!backup_and_metadata.is_complete) {
|
||||
await showAlertDialog(this, {
|
||||
title: "Backup is incomplete",
|
||||
text: "A backup has been created but it is incomplete and cannot be restored. This is a coordinator firmware limitation.",
|
||||
});
|
||||
}
|
||||
|
||||
const backupJSON: string =
|
||||
"data:text/plain;charset=utf-8," +
|
||||
encodeURIComponent(JSON.stringify(backup_and_metadata.backup, null, 4));
|
||||
const backupTime: Date = new Date(
|
||||
Date.parse(backup_and_metadata.backup.backup_time)
|
||||
);
|
||||
@@ -445,23 +441,7 @@ class ZHAConfigDashboard extends LitElement {
|
||||
basename = `Incomplete ${basename}`;
|
||||
}
|
||||
|
||||
const blob = new Blob(
|
||||
[JSON.stringify(backup_and_metadata.backup, null, 4)],
|
||||
{ type: "application/json" }
|
||||
);
|
||||
fileDownload(URL.createObjectURL(blob), `${basename}.json`);
|
||||
button.actionSuccess();
|
||||
|
||||
if (!backup_and_metadata.is_complete) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.backup_incomplete_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.backup_incomplete_text"
|
||||
),
|
||||
});
|
||||
}
|
||||
fileDownload(backupJSON, `${basename}.json`);
|
||||
}
|
||||
|
||||
private _openOptionFlow() {
|
||||
@@ -537,6 +517,10 @@ class ZHAConfigDashboard extends LitElement {
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
|
||||
ha-button[size="s"] ha-svg-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
|
||||
.network-status div.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+6
-115
@@ -4,7 +4,6 @@ import {
|
||||
mdiCloseCircle,
|
||||
mdiProgressClock,
|
||||
} from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -27,10 +26,10 @@ import "../../../../../components/ha-settings-row";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import "../../../../../components/input/ha-input";
|
||||
import type {
|
||||
ZWaveJSNodeCapabilities,
|
||||
ZWaveJSNodeConfigParam,
|
||||
ZWaveJSNodeConfigParams,
|
||||
ZWaveJSSetConfigParamResult,
|
||||
ZwaveJSNodeConfigParameterUpdate,
|
||||
ZwaveJSNodeMetadata,
|
||||
} from "../../../../../data/zwave_js";
|
||||
import {
|
||||
@@ -39,7 +38,6 @@ import {
|
||||
fetchZwaveNodeMetadata,
|
||||
invokeZWaveCCApi,
|
||||
setZwaveNodeConfigParameter,
|
||||
subscribeZwaveNodeConfigParameterUpdates,
|
||||
} from "../../../../../data/zwave_js";
|
||||
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../../layouts/hass-error-screen";
|
||||
@@ -62,7 +60,7 @@ const icons = {
|
||||
|
||||
@customElement("zwave_js-node-config")
|
||||
class ZWaveJSNodeConfig extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@@ -86,41 +84,13 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
|
||||
@state() private _resetDialogProgress = false;
|
||||
|
||||
private _unsubConfigParamUpdates?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _resultTimeouts: Record<string, number> = {};
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._subscribeConfigParameterUpdates();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeConfigParameterUpdates();
|
||||
this._clearAllResultTimeouts();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (!changedProps.has("route") || !this.route) {
|
||||
return;
|
||||
}
|
||||
const deviceId = this.route.path.slice(1);
|
||||
if (deviceId !== this.deviceId) {
|
||||
this.deviceId = deviceId;
|
||||
this._config = undefined;
|
||||
this._clearAllResultTimeouts();
|
||||
this._results = {};
|
||||
this._error = undefined;
|
||||
}
|
||||
this.deviceId = this.route.path.substr(1);
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
if (changedProps.has("deviceId")) {
|
||||
this._fetchData();
|
||||
this._subscribeConfigParameterUpdates();
|
||||
} else if (!this._config) {
|
||||
if (!this._config || changedProps.has("deviceId")) {
|
||||
this._fetchData();
|
||||
}
|
||||
}
|
||||
@@ -475,58 +445,6 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
<p>${item.value}</p>`;
|
||||
}
|
||||
|
||||
private _subscribeConfigParameterUpdates(): void {
|
||||
this._unsubscribeConfigParameterUpdates();
|
||||
if (!this.isConnected || !this.hass || !this.deviceId) {
|
||||
return;
|
||||
}
|
||||
this._unsubConfigParamUpdates = subscribeZwaveNodeConfigParameterUpdates(
|
||||
this.hass,
|
||||
this.deviceId,
|
||||
this._handleConfigParameterUpdate
|
||||
);
|
||||
this._unsubConfigParamUpdates.catch(() => {
|
||||
// The backend doesn't support the subscription; the page still works,
|
||||
// it just won't receive live updates
|
||||
this._unsubConfigParamUpdates = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private _unsubscribeConfigParameterUpdates(): void {
|
||||
if (this._unsubConfigParamUpdates) {
|
||||
this._unsubConfigParamUpdates
|
||||
.then((unsub) => unsub())
|
||||
.catch(() => {
|
||||
// The subscription never succeeded, so there is nothing to clean up
|
||||
});
|
||||
this._unsubConfigParamUpdates = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleConfigParameterUpdate = (
|
||||
update: ZwaveJSNodeConfigParameterUpdate
|
||||
): void => {
|
||||
const param = this._config?.[update.id];
|
||||
if (!param) {
|
||||
return;
|
||||
}
|
||||
const status = this._results[update.id]?.status;
|
||||
if (status === "queued") {
|
||||
// The device applied the queued change; the accepted result is cleared
|
||||
// after a short delay by _setResult so the success message shows.
|
||||
// The value was already set optimistically when the change was queued,
|
||||
// so this runs even if the reported value matches the stored one.
|
||||
this._setResult(update.id, "accepted");
|
||||
} else if (status === "error" && param.value !== update.value) {
|
||||
// The parameter changed, so the previous error no longer applies
|
||||
this._setResult(update.id, undefined);
|
||||
}
|
||||
if (param.value !== update.value) {
|
||||
param.value = update.value;
|
||||
this._config = { ...this._config };
|
||||
}
|
||||
};
|
||||
|
||||
private _isEnumeratedBool(item: ZWaveJSNodeConfigParam): boolean {
|
||||
// Some Z-Wave config values use a states list with two options where index 0 = Disabled and 1 = Enabled
|
||||
// We want those to be considered boolean and show a toggle switch
|
||||
@@ -693,38 +611,16 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _clearResultTimeout(key: string): void {
|
||||
if (key in this._resultTimeouts) {
|
||||
clearTimeout(this._resultTimeouts[key]);
|
||||
delete this._resultTimeouts[key];
|
||||
}
|
||||
}
|
||||
|
||||
private _clearAllResultTimeouts(): void {
|
||||
Object.values(this._resultTimeouts).forEach((timeout) =>
|
||||
clearTimeout(timeout)
|
||||
);
|
||||
this._resultTimeouts = {};
|
||||
}
|
||||
|
||||
private _setResult(key: string, value: string | undefined) {
|
||||
this._clearResultTimeout(key);
|
||||
if (value === undefined) {
|
||||
delete this._results[key];
|
||||
this.requestUpdate();
|
||||
} else {
|
||||
this._results = { ...this._results, [key]: { status: value } };
|
||||
if (value === "accepted") {
|
||||
// Show the success message briefly, then clear it
|
||||
this._resultTimeouts[key] = window.setTimeout(() => {
|
||||
this._setResult(key, undefined);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _setError(key: string, message: string) {
|
||||
this._clearResultTimeout(key);
|
||||
const errorParam = { status: "error", error: message };
|
||||
this._results = { ...this._results, [key]: errorParam };
|
||||
}
|
||||
@@ -740,17 +636,12 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const [nodeMetadata, config, capabilities] = await Promise.all([
|
||||
let capabilities: ZWaveJSNodeCapabilities | undefined;
|
||||
[this._nodeMetadata, this._config, capabilities] = await Promise.all([
|
||||
fetchZwaveNodeMetadata(this.hass, device.id),
|
||||
fetchZwaveNodeConfigParameters(this.hass, device.id),
|
||||
fetchZwaveNodeCapabilities(this.hass, device.id),
|
||||
]);
|
||||
if (device.id !== this.deviceId) {
|
||||
// The user navigated to another node while the data was loading
|
||||
return;
|
||||
}
|
||||
this._nodeMetadata = nodeMetadata;
|
||||
this._config = config;
|
||||
this._canResetAll =
|
||||
capabilities &&
|
||||
Object.values(capabilities).some((endpoint) =>
|
||||
|
||||
@@ -130,19 +130,6 @@ class PanelHome extends LitElement {
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
void import("../lovelace/strategies/home/home-dashboard-strategy").catch(
|
||||
() => undefined
|
||||
);
|
||||
void import("../lovelace/strategies/home/home-overview-view-strategy")
|
||||
.then(({ preloadHomeEnergyPreferences }) => {
|
||||
const path = this.route?.path?.split("/")[1];
|
||||
return !path || path === "overview"
|
||||
? preloadHomeEnergyPreferences(this.hass)
|
||||
: undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
void import("../lovelace/views/hui-sections-view").catch(() => undefined);
|
||||
|
||||
this._updateExtraActionItems();
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await this._loadConfigPromise;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "../../../../common/entity/entity_filter";
|
||||
import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
|
||||
import type { AreaRegistryEntry } from "../../../../data/area/area_registry";
|
||||
import type { EnergyPreferences } from "../../../../data/energy";
|
||||
import { getEnergyPreferences } from "../../../../data/energy";
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import type {
|
||||
@@ -51,26 +50,6 @@ export interface HomeOverviewViewStrategyConfig {
|
||||
shortcuts?: ShortcutItem[];
|
||||
}
|
||||
|
||||
const energyPreferencesPromises = new WeakMap<
|
||||
HomeAssistant["connection"],
|
||||
Promise<EnergyPreferences | undefined>
|
||||
>();
|
||||
|
||||
export const preloadHomeEnergyPreferences = (hass: HomeAssistant) => {
|
||||
if (!isComponentLoaded(hass.config, "energy")) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
const existing = energyPreferencesPromises.get(hass.connection);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = getEnergyPreferences(hass).catch(() => undefined);
|
||||
energyPreferencesPromises.set(hass.connection, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const computeAreaCard = (
|
||||
areaId: string,
|
||||
hass: HomeAssistant
|
||||
@@ -326,8 +305,10 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
.filter(weatherFilter)
|
||||
.sort()[0];
|
||||
|
||||
const energyPrefs = await preloadHomeEnergyPreferences(hass);
|
||||
energyPreferencesPromises.delete(hass.connection);
|
||||
const energyPrefs = isComponentLoaded(hass.config, "energy")
|
||||
? // It raises if not configured, just swallow that.
|
||||
await getEnergyPreferences(hass).catch(() => undefined)
|
||||
: undefined;
|
||||
|
||||
const hasEnergy =
|
||||
hass.panels.energy &&
|
||||
|
||||
@@ -522,9 +522,6 @@
|
||||
"show_password": "Show password",
|
||||
"hide_password": "Hide password"
|
||||
},
|
||||
"form": {
|
||||
"validation_failed": "Form validation failed."
|
||||
},
|
||||
"selectors": {
|
||||
"serial_port": {
|
||||
"enter_manually": "Enter manually",
|
||||
@@ -7557,9 +7554,6 @@
|
||||
"download_backup": "Download backup",
|
||||
"download_backup_description": "Save your Zigbee network configuration to a file",
|
||||
"download_backup_action": "Download",
|
||||
"backup_failed": "Failed to create backup",
|
||||
"backup_incomplete_title": "Backup is incomplete",
|
||||
"backup_incomplete_text": "A backup has been created but it is incomplete and cannot be restored. This is a limitation of your adapter's firmware.",
|
||||
"migrate_radio": "Migrate adapter",
|
||||
"migrate_radio_description": "Move your Zigbee network to a different adapter",
|
||||
"migrate_radio_action": "Migrate",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { html, nothing, render } from "lit";
|
||||
import "../../src/components/data-table/ha-data-table";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
DataTableRowData,
|
||||
HaDataTable,
|
||||
} from "../../src/components/data-table/ha-data-table";
|
||||
|
||||
const columns: DataTableColumnContainer = {
|
||||
name: { title: "Name", main: true },
|
||||
area: { title: "Area" },
|
||||
category: { title: "Category" },
|
||||
empty_template: { title: "Empty", template: () => nothing },
|
||||
filled_template: { title: "Filled", template: () => html`filled` },
|
||||
};
|
||||
|
||||
// The narrow row puts every non-main column on a secondary line, joined by dots.
|
||||
const renderNarrowSecondary = (row: DataTableRowData) => {
|
||||
const el = document.createElement("ha-data-table") as HaDataTable;
|
||||
const container = document.createElement("div");
|
||||
render((el as any)._renderRow(columns, true, row, 0), container);
|
||||
return container.querySelector(".secondary")!.textContent!.trim();
|
||||
};
|
||||
|
||||
describe("ha-data-table narrow secondary line", () => {
|
||||
it("does not render separators for empty columns", () => {
|
||||
expect(renderNarrowSecondary({ id: "1", name: "Test" })).toBe("filled");
|
||||
});
|
||||
|
||||
it("separates only the columns that have a value", () => {
|
||||
expect(
|
||||
renderNarrowSecondary({ id: "1", name: "Test", area: "Kitchen" })
|
||||
).toBe("Kitchen · filled");
|
||||
});
|
||||
|
||||
it("renders a blank secondary line when all secondary columns are empty", () => {
|
||||
const emptyColumns: DataTableColumnContainer = {
|
||||
name: { title: "Name", main: true },
|
||||
area: { title: "Area" },
|
||||
category: { title: "Category" },
|
||||
empty_template: { title: "Empty", template: () => nothing },
|
||||
};
|
||||
const el = document.createElement("ha-data-table") as HaDataTable;
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
(el as any)._renderRow(emptyColumns, true, { id: "1", name: "Test" }, 0),
|
||||
container
|
||||
);
|
||||
expect(container.querySelector(".secondary")!.textContent!.trim()).toBe("");
|
||||
});
|
||||
});
|
||||
+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;
|
||||
|
||||
@@ -27,28 +27,43 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@asamuzakjp/css-color@npm:^6.0.5":
|
||||
version: 6.0.5
|
||||
resolution: "@asamuzakjp/css-color@npm:6.0.5"
|
||||
"@asamuzakjp/css-color@npm:^5.1.11":
|
||||
version: 5.1.11
|
||||
resolution: "@asamuzakjp/css-color@npm:5.1.11"
|
||||
dependencies:
|
||||
"@csstools/css-calc": "npm:^3.2.1"
|
||||
"@csstools/css-color-parser": "npm:^4.1.9"
|
||||
"@asamuzakjp/generational-cache": "npm:^1.0.1"
|
||||
"@csstools/css-calc": "npm:^3.2.0"
|
||||
"@csstools/css-color-parser": "npm:^4.1.0"
|
||||
"@csstools/css-parser-algorithms": "npm:^4.0.0"
|
||||
"@csstools/css-tokenizer": "npm:^4.0.0"
|
||||
lru-cache: "npm:^11.5.2"
|
||||
checksum: 10/bd88a9a1d00711f5f63c4288a82489c33e28de43dee37f418cd30a42517d7b7e2040dc79fc223ececf290e7b3f12bbd2dd8ce7450f73b03bf0613df449b9563f
|
||||
checksum: 10/2e337cc94b5a3f9741a27f92b4e4b7dc467a76b1dcf66c40e71808fed71695f10c8cf07c8b13313cbb637154314ca1d8626bb9a045fe94b404b242a390cf3bd3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@asamuzakjp/dom-selector@npm:^8.2.5":
|
||||
version: 8.3.0
|
||||
resolution: "@asamuzakjp/dom-selector@npm:8.3.0"
|
||||
"@asamuzakjp/dom-selector@npm:^7.1.1":
|
||||
version: 7.1.1
|
||||
resolution: "@asamuzakjp/dom-selector@npm:7.1.1"
|
||||
dependencies:
|
||||
"@asamuzakjp/generational-cache": "npm:^1.0.1"
|
||||
"@asamuzakjp/nwsapi": "npm:^2.3.9"
|
||||
bidi-js: "npm:^1.0.3"
|
||||
css-tree: "npm:^3.2.1"
|
||||
is-potential-custom-element-name: "npm:^1.0.1"
|
||||
lru-cache: "npm:^11.5.2"
|
||||
checksum: 10/458632671954613e1bd653c9c9d2e8ded44701f19ca8a9641ec3fa0df9eb60089769e0c98dd7991afd53b86e40f8a720024653f451af71ee644d6cf3241c9ec0
|
||||
checksum: 10/49a065a64db5f53a3008c231d09606e4b67f509fa20148a67419451c2dc91a421202ed17bfc4bc679ad2f0432d7260720d602c1d5c9c5e165931fff5199c3f12
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@asamuzakjp/generational-cache@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "@asamuzakjp/generational-cache@npm:1.0.1"
|
||||
checksum: 10/e1cf3f1916a334c6153f624982f0eb3d50fa3048435ea5c5b0f441f8f1ab74a0fe992dac214b612d22c0acafad3cd1a1f6b45d99c7b6e3b63cfdf7f6ca5fc144
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@asamuzakjp/nwsapi@npm:^2.3.9":
|
||||
version: 2.3.9
|
||||
resolution: "@asamuzakjp/nwsapi@npm:2.3.9"
|
||||
checksum: 10/95a6d1c102e1117fe818da087fcc5b914d23e0699855991bae50b891435dd1945ad7d384198f8bcf616207fd85b7ec32e3db6b96e9309d84c6903b8dc4151e34
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2439,15 +2454,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@codemirror/view@npm:6.43.7, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.42.0":
|
||||
version: 6.43.7
|
||||
resolution: "@codemirror/view@npm:6.43.7"
|
||||
"@codemirror/view@npm:6.43.6, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.42.0":
|
||||
version: 6.43.6
|
||||
resolution: "@codemirror/view@npm:6.43.6"
|
||||
dependencies:
|
||||
"@codemirror/state": "npm:^6.7.0"
|
||||
crelt: "npm:^1.0.6"
|
||||
style-mod: "npm:^4.1.0"
|
||||
w3c-keyname: "npm:^2.2.4"
|
||||
checksum: 10/dbc7c4e9162099dae15be1107e831c955c778fa224e24f4fd02d6eb9e75bc6c6d636e3e9fce3dc5852576c392c5793f4f6c5838f932a9c9e77f32a4ca66f62bf
|
||||
checksum: 10/be058e86523d5770921c6bad7963eb5ef0a7db340922db93043394e1e93d1f3125611a3c2dbf143f1ab69b426038cd2034ffa7484d4b55d1ac909f2ceed2ee06
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2467,26 +2482,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@csstools/css-calc@npm:^3.2.1, @csstools/css-calc@npm:^3.3.0":
|
||||
version: 3.3.0
|
||||
resolution: "@csstools/css-calc@npm:3.3.0"
|
||||
"@csstools/css-calc@npm:^3.2.0, @csstools/css-calc@npm:^3.2.1":
|
||||
version: 3.2.1
|
||||
resolution: "@csstools/css-calc@npm:3.2.1"
|
||||
peerDependencies:
|
||||
"@csstools/css-parser-algorithms": ^4.0.0
|
||||
"@csstools/css-tokenizer": ^4.0.0
|
||||
checksum: 10/46dde1c26c8a62d7b849a616a521cb97abc2d6c8c480f7be6f14c70fe60689a2a58c1f5f905aae237250ad27172968e10c70f6f83864c7dc48d8f8921f55f6a9
|
||||
checksum: 10/39042a9382cbd7c4fa241c1a6c10d64c23fa7653970fc11df532e9bc42a366a8b50c3ac3036bd5f2a77b94144e7f804f19d2b52800ad2f48bd9a3840377165d8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@csstools/css-color-parser@npm:^4.1.9":
|
||||
version: 4.1.10
|
||||
resolution: "@csstools/css-color-parser@npm:4.1.10"
|
||||
"@csstools/css-color-parser@npm:^4.1.0":
|
||||
version: 4.1.9
|
||||
resolution: "@csstools/css-color-parser@npm:4.1.9"
|
||||
dependencies:
|
||||
"@csstools/color-helpers": "npm:^6.1.0"
|
||||
"@csstools/css-calc": "npm:^3.3.0"
|
||||
"@csstools/css-calc": "npm:^3.2.1"
|
||||
peerDependencies:
|
||||
"@csstools/css-parser-algorithms": ^4.0.0
|
||||
"@csstools/css-tokenizer": ^4.0.0
|
||||
checksum: 10/92f5f590617a2cc9ff358b2d79716cbe15dd4cc7537cdc45ffc55878282677a9ef6c8bfb215a7dc0731f302c32bf1b0d1b7fc3c550147fd8a6d85f7c3cfb0cdd
|
||||
checksum: 10/6369b601bcd3a8ce58dbcdc389a732b754c8f3fc35421b37169f6d4b07c7261f7b8c23e7d04e457da5e5e0bb082e680acb137d8c86fa1f7d6ff14ea873bf02a2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2499,15 +2514,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree@npm:^1.1.6":
|
||||
version: 1.1.7
|
||||
resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.7"
|
||||
"@csstools/css-syntax-patches-for-csstree@npm:^1.1.3":
|
||||
version: 1.1.6
|
||||
resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.6"
|
||||
peerDependencies:
|
||||
css-tree: ^3.2.1
|
||||
peerDependenciesMeta:
|
||||
css-tree:
|
||||
optional: true
|
||||
checksum: 10/ba372ab41c351509d47e77f58eb56112fe6fd42e104b70c9c9c797faf4fbb3e837a5df5c9d7e3464b04607ee42e91701e34f935aeec8abb94f24be67cfa1b707
|
||||
checksum: 10/8747268b42f1afbe450d38b7f388a4983c810883f8c0716fa24f5b1eef0f9cbaa3a0f4ea72d1e5cbd015dc4fd5af9cc96ade42792912ed2dcc1d4054de102163
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2711,7 +2726,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.15.1, @exodus/bytes@npm:^1.6.0":
|
||||
"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.15.0, @exodus/bytes@npm:^1.6.0":
|
||||
version: 1.15.1
|
||||
resolution: "@exodus/bytes@npm:1.15.1"
|
||||
peerDependencies:
|
||||
@@ -4740,65 +4755,65 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-darwin-arm64@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.1.6"
|
||||
"@rspack/binding-darwin-arm64@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.1.5"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-darwin-x64@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.1.6"
|
||||
"@rspack/binding-darwin-x64@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.1.5"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-arm64-gnu@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.6"
|
||||
"@rspack/binding-linux-arm64-gnu@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.5"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-arm64-musl@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.6"
|
||||
"@rspack/binding-linux-arm64-musl@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.5"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-gnu@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.6"
|
||||
"@rspack/binding-linux-riscv64-gnu@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.5"
|
||||
conditions: os=linux & cpu=riscv64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-musl@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.6"
|
||||
"@rspack/binding-linux-riscv64-musl@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.5"
|
||||
conditions: os=linux & cpu=riscv64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.6"
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.5"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-musl@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.6"
|
||||
"@rspack/binding-linux-x64-musl@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.5"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-wasm32-wasi@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.6"
|
||||
"@rspack/binding-wasm32-wasi@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.5"
|
||||
dependencies:
|
||||
"@emnapi/core": "npm:1.11.2"
|
||||
"@emnapi/runtime": "npm:1.11.2"
|
||||
@@ -4807,43 +4822,43 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-arm64-msvc@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.6"
|
||||
"@rspack/binding-win32-arm64-msvc@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.5"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-ia32-msvc@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.6"
|
||||
"@rspack/binding-win32-ia32-msvc@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.5"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-x64-msvc@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.6"
|
||||
"@rspack/binding-win32-x64-msvc@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.5"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/binding@npm:2.1.6"
|
||||
"@rspack/binding@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/binding@npm:2.1.5"
|
||||
dependencies:
|
||||
"@rspack/binding-darwin-arm64": "npm:2.1.6"
|
||||
"@rspack/binding-darwin-x64": "npm:2.1.6"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.1.6"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.1.6"
|
||||
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.6"
|
||||
"@rspack/binding-linux-riscv64-musl": "npm:2.1.6"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.1.6"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.1.6"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.1.6"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.1.6"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.1.6"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.1.6"
|
||||
"@rspack/binding-darwin-arm64": "npm:2.1.5"
|
||||
"@rspack/binding-darwin-x64": "npm:2.1.5"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.1.5"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.1.5"
|
||||
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.5"
|
||||
"@rspack/binding-linux-riscv64-musl": "npm:2.1.5"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.1.5"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.1.5"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.1.5"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.1.5"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.1.5"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.1.5"
|
||||
dependenciesMeta:
|
||||
"@rspack/binding-darwin-arm64":
|
||||
optional: true
|
||||
@@ -4869,15 +4884,15 @@ __metadata:
|
||||
optional: true
|
||||
"@rspack/binding-win32-x64-msvc":
|
||||
optional: true
|
||||
checksum: 10/0d164b7425f448ed6b8b81e687f4a82377289476cee41553ced9532e211d216fa13429b9125f3e3f025e391709bf752983c16eb4549341a21cbd0c61510afbeb
|
||||
checksum: 10/d1054348d3ba734485f977d574c6311311707adfcc5e53f03c8dbbbdc5778cff9b815e1d675992e2832241606d4cce7678acc3999b259139120b4ec30751f1e1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/core@npm:2.1.6":
|
||||
version: 2.1.6
|
||||
resolution: "@rspack/core@npm:2.1.6"
|
||||
"@rspack/core@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@rspack/core@npm:2.1.5"
|
||||
dependencies:
|
||||
"@rspack/binding": "npm:2.1.6"
|
||||
"@rspack/binding": "npm:2.1.5"
|
||||
peerDependencies:
|
||||
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
|
||||
"@swc/helpers": ^0.5.23
|
||||
@@ -4886,7 +4901,7 @@ __metadata:
|
||||
optional: true
|
||||
"@swc/helpers":
|
||||
optional: true
|
||||
checksum: 10/334f8e13aa3540b7320d3186250fc4e2f128711f765d2073274188f23c692d4262e7cbb88c10571626b55e480186c474bdbcb564e7fa97df803479554de855b4
|
||||
checksum: 10/714064de701211724f7d859bc269d357fa2ad2f7ea0ac34d1e4e48b534981cf3961bd723c0659b9c093330f04e01e16a0c5587d6a18301727e27d717f764ded7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9660,10 +9675,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"globals@npm:17.8.0":
|
||||
version: 17.8.0
|
||||
resolution: "globals@npm:17.8.0"
|
||||
checksum: 10/b7b854b2052d2608d1878884bf730c027e17b9e2d194834f48c3280a7f0005b06d5f51dba362d3149cc05eb90d36d990e5c996728ecd3585a43dc3b4a286ed85
|
||||
"globals@npm:17.7.0":
|
||||
version: 17.7.0
|
||||
resolution: "globals@npm:17.7.0"
|
||||
checksum: 10/79304ccc4d2ca167ea15bdb25da346aa34ce3847b18fbd6c3cad182e152505305db3c9722fd5e292c62f6db97a8fa06e0c110a1e7703d7325498e5351d08cab4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9900,7 +9915,7 @@ __metadata:
|
||||
"@codemirror/lint": "npm:6.9.7"
|
||||
"@codemirror/search": "npm:6.7.1"
|
||||
"@codemirror/state": "npm:6.7.1"
|
||||
"@codemirror/view": "npm:6.43.7"
|
||||
"@codemirror/view": "npm:6.43.6"
|
||||
"@date-fns/tz": "npm:1.5.0"
|
||||
"@egjs/hammerjs": "npm:2.0.17"
|
||||
"@eslint/js": "npm:10.0.1"
|
||||
@@ -9940,7 +9955,7 @@ __metadata:
|
||||
"@playwright/test": "npm:1.62.0"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.6.1"
|
||||
"@rspack/core": "npm:2.1.6"
|
||||
"@rspack/core": "npm:2.1.5"
|
||||
"@rspack/dev-server": "npm:2.1.0"
|
||||
"@swc/helpers": "npm:0.5.23"
|
||||
"@thomasloven/round-slider": "npm:0.6.0"
|
||||
@@ -9996,7 +10011,7 @@ __metadata:
|
||||
fuse.js: "npm:7.5.0"
|
||||
generate-license-file: "npm:4.2.1"
|
||||
glob: "npm:13.0.6"
|
||||
globals: "npm:17.8.0"
|
||||
globals: "npm:17.7.0"
|
||||
gulp: "npm:5.0.1"
|
||||
gulp-brotli: "npm:3.0.0"
|
||||
gulp-json-transform: "npm:0.5.0"
|
||||
@@ -10009,7 +10024,7 @@ __metadata:
|
||||
idb-keyval: "npm:6.3.0"
|
||||
intl-messageformat: "npm:11.2.12"
|
||||
js-yaml: "npm:5.2.2"
|
||||
jsdom: "npm:30.0.0"
|
||||
jsdom: "npm:29.1.1"
|
||||
jszip: "npm:3.10.1"
|
||||
leaflet: "npm:1.9.4"
|
||||
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
|
||||
@@ -10964,37 +10979,37 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsdom@npm:30.0.0":
|
||||
version: 30.0.0
|
||||
resolution: "jsdom@npm:30.0.0"
|
||||
"jsdom@npm:29.1.1":
|
||||
version: 29.1.1
|
||||
resolution: "jsdom@npm:29.1.1"
|
||||
dependencies:
|
||||
"@asamuzakjp/css-color": "npm:^6.0.5"
|
||||
"@asamuzakjp/dom-selector": "npm:^8.2.5"
|
||||
"@asamuzakjp/css-color": "npm:^5.1.11"
|
||||
"@asamuzakjp/dom-selector": "npm:^7.1.1"
|
||||
"@bramus/specificity": "npm:^2.4.2"
|
||||
"@csstools/css-syntax-patches-for-csstree": "npm:^1.1.6"
|
||||
"@exodus/bytes": "npm:^1.15.1"
|
||||
"@csstools/css-syntax-patches-for-csstree": "npm:^1.1.3"
|
||||
"@exodus/bytes": "npm:^1.15.0"
|
||||
css-tree: "npm:^3.2.1"
|
||||
data-urls: "npm:^7.0.0"
|
||||
decimal.js: "npm:^10.6.0"
|
||||
html-encoding-sniffer: "npm:^6.0.0"
|
||||
is-potential-custom-element-name: "npm:^1.0.1"
|
||||
lru-cache: "npm:^11.5.2"
|
||||
lru-cache: "npm:^11.3.5"
|
||||
parse5: "npm:^8.0.1"
|
||||
saxes: "npm:^6.0.0"
|
||||
symbol-tree: "npm:^3.2.4"
|
||||
tough-cookie: "npm:^6.0.2"
|
||||
undici: "npm:^8.7.0"
|
||||
tough-cookie: "npm:^6.0.1"
|
||||
undici: "npm:^7.25.0"
|
||||
w3c-xmlserializer: "npm:^5.0.0"
|
||||
webidl-conversions: "npm:^8.0.1"
|
||||
whatwg-mimetype: "npm:^5.0.0"
|
||||
whatwg-url: "npm:^17.1.0"
|
||||
whatwg-url: "npm:^16.0.1"
|
||||
xml-name-validator: "npm:^5.0.0"
|
||||
peerDependencies:
|
||||
canvas: ^3.2.3
|
||||
canvas: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
checksum: 10/dc73c071e67224465018733525047d05772667bd8e00a3703a6f6515238c027b6ca768bf86874a53d512fd0bb962d72cc6a6c31c1bc38e52318f9d412ad0bb90
|
||||
checksum: 10/344aed7f91839b6c7d1b40778c5542d6ded7d42d88e1b787e10bf12d4ccd65464a5f23f774eb84350885c75a48efc99f6972adbb94dffe324a1b065d3650843c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11580,7 +11595,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1, lru-cache@npm:^11.5.2":
|
||||
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1, lru-cache@npm:^11.3.5":
|
||||
version: 11.5.2
|
||||
resolution: "lru-cache@npm:11.5.2"
|
||||
checksum: 10/122789c66605ddf29df58ff279e9e2c9499499d0b80b895ec524496f14bcde045d1582e3e38008f3457226d98067556719573b352119d6be8bdb1f8849f85681
|
||||
@@ -14730,7 +14745,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tough-cookie@npm:^6.0.2":
|
||||
"tough-cookie@npm:^6.0.1":
|
||||
version: 6.0.2
|
||||
resolution: "tough-cookie@npm:6.0.2"
|
||||
dependencies:
|
||||
@@ -15056,10 +15071,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici@npm:^8.4.1, undici@npm:^8.7.0":
|
||||
version: 8.9.0
|
||||
resolution: "undici@npm:8.9.0"
|
||||
checksum: 10/dfad3e233087eafdf1d361acd17c4d45a9590d5db9400458f1c03f47d8f1ef68647e2109ebb982c862e50c227093abd2d5f44b154904fc0b3d22576b6e95cfe7
|
||||
"undici@npm:^7.25.0":
|
||||
version: 7.28.0
|
||||
resolution: "undici@npm:7.28.0"
|
||||
checksum: 10/154423b280d623278a61decb437f8a7e581fb18b8c95556ef956b32a58cd668eadbb812d28e20678cb2dc545a566f35a3afc0962307ca801da30f4741117986d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici@npm:^8.4.1":
|
||||
version: 8.7.0
|
||||
resolution: "undici@npm:8.7.0"
|
||||
checksum: 10/e9644903bda97f825228b4c7bee95b3586609f94df685b598c32bdaf3ac795928cbd25b0ed2690c3ea0b5abd324f5a9641b63b6c81c9b19f3ef4d5b3e402a48c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15688,7 +15710,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"whatwg-url@npm:^16.0.0":
|
||||
"whatwg-url@npm:^16.0.0, whatwg-url@npm:^16.0.1":
|
||||
version: 16.0.1
|
||||
resolution: "whatwg-url@npm:16.0.1"
|
||||
dependencies:
|
||||
@@ -15699,17 +15721,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"whatwg-url@npm:^17.1.0":
|
||||
version: 17.1.0
|
||||
resolution: "whatwg-url@npm:17.1.0"
|
||||
dependencies:
|
||||
"@exodus/bytes": "npm:^1.15.1"
|
||||
tr46: "npm:^6.0.0"
|
||||
webidl-conversions: "npm:^8.0.1"
|
||||
checksum: 10/52052ba12a63e7665ee49d84d251a3d4776be4777fb259155ea3ca9bc49cdbbd18cbb5fce50f187e8450ce81d7abbbc4c39cfb15207c6d11d5c2539f9b87d426
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"whatwg-url@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "whatwg-url@npm:5.0.0"
|
||||
|
||||
Reference in New Issue
Block a user