You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

303 lines
9.6 KiB

#!/usr/bin/env node
"use strict";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
require("node:module").Module._initPaths();
function parseArguments(argv) {
const values = {
baseUrl: process.env.HASS_PLAYWRIGHT_BASE_URL || "http://192.168.10.10:8123",
outputDir: process.env.HASS_PLAYWRIGHT_OUTPUT_DIR || "output/playwright/ha-ui-smoke",
routesJson: process.env.HASS_UI_ROUTES_JSON || "[]",
headed: false,
};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--headed") {
values.headed = true;
continue;
}
const next = argv[index + 1];
if (!next) {
throw new Error(`Missing value for ${argument}`);
}
if (argument === "--base-url") {
values.baseUrl = next;
} else if (argument === "--output-dir") {
values.outputDir = next;
} else if (argument === "--routes-json") {
values.routesJson = next;
} else {
throw new Error(`Unknown argument ${argument}`);
}
index += 1;
}
return values;
}
function loadPlaywright() {
const modulePath = process.env.PLAYWRIGHT_MODULE_PATH;
try {
return modulePath ? require(modulePath) : require("playwright");
} catch (error) {
throw new Error(
"Playwright is not available. Install the playwright package or set "
+ "PLAYWRIGHT_MODULE_PATH to its module directory. "
+ `Original error: ${error.message}`,
);
}
}
function findBrowserExecutable(chromium) {
const configured = process.env.HASS_PLAYWRIGHT_EXECUTABLE_PATH;
if (configured) {
if (!fs.existsSync(configured)) {
throw new Error(`Configured browser executable does not exist: ${configured}`);
}
return configured;
}
const candidates = [];
try {
candidates.push(chromium.executablePath());
} catch {
// Fall through to installed browser candidates.
}
if (process.platform === "win32") {
candidates.push(
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
);
}
return candidates.find((candidate) => candidate && fs.existsSync(candidate));
}
function safeScreenshotName(route) {
const value = route.replace(/^\/+/, "").replace(/[^A-Za-z0-9_-]+/g, "-");
return value || "root";
}
function expectedView(route) {
const parts = route.split("/").filter(Boolean);
if (parts.length <= 1) {
return { index: 0, path: null };
}
const requested = parts[parts.length - 1];
if (/^\d+$/.test(requested)) {
return { index: Number(requested), path: null };
}
return { index: null, path: requested };
}
function isIgnoredConsoleError(message) {
return [
/ResizeObserver loop/i,
/favicon\.ico/i,
/^Failed to load resource: the server responded with a status of \d+ \([^)]+\)$/,
].some((pattern) => pattern.test(message));
}
function isIgnoredFailedResponse(response) {
if (response.status() !== 404) {
return false;
}
const url = new URL(response.url());
// Bubble Card probes this legacy optional file when Bubble Card Tools is absent.
return url.pathname === "/local/bubble/bubble-modules.yaml";
}
async function validateRoute(page, baseUrl, route, outputDir) {
const consoleErrors = [];
const pageErrors = [];
const failedResponses = [];
const onConsole = (message) => {
if (message.type() === "error" && !isIgnoredConsoleError(message.text())) {
consoleErrors.push(message.text());
}
};
const onPageError = (error) => pageErrors.push(error.message);
const onResponse = (response) => {
if (response.status() >= 400 && !isIgnoredFailedResponse(response)) {
failedResponses.push(`${response.status()} ${response.url()}`);
}
};
page.on("console", onConsole);
page.on("pageerror", onPageError);
page.on("response", onResponse);
const failures = [];
const screenshotPath = path.join(outputDir, `${safeScreenshotName(route)}.png`);
const url = `${baseUrl.replace(/\/$/, "")}${route}`;
try {
const response = await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: 30_000,
});
if (!response || response.status() < 200 || response.status() >= 400) {
failures.push(`HTTP status ${response ? response.status() : "missing"}`);
}
if (/\/(login|auth\/authorize)\b/.test(page.url())) {
failures.push(`redirected to authentication at ${page.url()}`);
}
await page.locator("home-assistant").waitFor({ state: "attached", timeout: 20_000 });
await page.locator("hui-root").waitFor({ state: "attached", timeout: 20_000 });
await page.locator("hui-root").evaluate(async (element) => {
const deadline = Date.now() + 20_000;
while (!element.lovelace?.config?.views) {
if (Date.now() >= deadline) {
throw new Error("Lovelace configuration did not finish loading");
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
});
await page.waitForTimeout(1_200);
const renderedViews = await page.locator("hui-view").count();
if (renderedViews < 1) {
failures.push("no rendered hui-view found");
}
// Some async custom cards briefly render an error card before their HA
// entity attributes arrive. Re-check after hydration before failing the
// route so the smoke test measures the settled UI rather than the load
// transition.
let errorCards = await page.locator("hui-error-card").count();
if (errorCards > 0) {
await page.waitForTimeout(4_000);
errorCards = await page.locator("hui-error-card").count();
}
if (errorCards > 0) {
failures.push(`${errorCards} hui-error-card element(s) rendered`);
}
const lovelace = await page.locator("hui-root").evaluate((element) => ({
paths: (element.lovelace?.config?.views || []).map((view) => view.path ?? null),
}));
const renderedIndex = await page.locator("hui-view").evaluate((element) => element.index);
const expected = expectedView(route);
if (expected.index !== null && renderedIndex !== expected.index) {
failures.push(
`requested view index ${expected.index}, Lovelace rendered ${renderedIndex}`,
);
}
if (expected.path !== null) {
const expectedIndex = lovelace.paths.indexOf(expected.path);
if (expectedIndex < 0) {
failures.push(`requested view path ${expected.path} is not configured`);
} else if (renderedIndex !== expectedIndex) {
failures.push(
`requested view path ${expected.path} at index ${expectedIndex}, `
+ `Lovelace rendered index ${renderedIndex}`,
);
}
}
if (pageErrors.length > 0) {
failures.push(`page errors: ${pageErrors.join(" | ")}`);
}
if (consoleErrors.length > 0) {
failures.push(`console errors: ${consoleErrors.join(" | ")}`);
}
if (failedResponses.length > 0) {
failures.push(`failed responses: ${failedResponses.join(" | ")}`);
}
} catch (error) {
failures.push(error.message);
} finally {
page.off("console", onConsole);
page.off("pageerror", onPageError);
page.off("response", onResponse);
}
if (failures.length > 0) {
fs.mkdirSync(outputDir, { recursive: true });
try {
await page.screenshot({ path: screenshotPath, fullPage: true });
} catch (error) {
failures.push(`screenshot failed: ${error.message}`);
}
return { route, failures, screenshotPath };
}
if (fs.existsSync(screenshotPath)) {
fs.unlinkSync(screenshotPath);
}
return { route, failures: [], screenshotPath: null };
}
async function main() {
const options = parseArguments(process.argv.slice(2));
let routes;
try {
routes = JSON.parse(options.routesJson);
} catch (error) {
throw new Error(`Invalid routes JSON: ${error.message}`);
}
if (!Array.isArray(routes) || routes.length === 0) {
throw new Error("At least one validated dashboard route is required");
}
if (routes.some((route) => typeof route !== "string" || !route.startsWith("/"))) {
throw new Error("Every dashboard route must be an absolute path string");
}
const { chromium } = loadPlaywright();
const executablePath = findBrowserExecutable(chromium);
const launchOptions = {
headless: !options.headed,
...(executablePath ? { executablePath } : {}),
};
const browser = await chromium.launch(launchOptions);
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const results = [];
try {
for (const route of routes) {
const page = await context.newPage();
try {
const result = await validateRoute(page, options.baseUrl, route, options.outputDir);
results.push(result);
if (result.failures.length === 0) {
console.log(`OK ${route}`);
} else {
console.error(`FAIL ${route}: ${result.failures.join("; ")}`);
console.error(` screenshot: ${result.screenshotPath}`);
}
} finally {
await page.close();
}
}
} finally {
await browser.close();
}
const failed = results.filter((result) => result.failures.length > 0);
if (failed.length > 0) {
console.error(`UI smoke failed: ${failed.length}/${results.length} routes failed`);
process.exitCode = 2;
return;
}
console.log(`UI smoke passed: ${results.length} rendered Lovelace routes validated`);
}
main().catch((error) => {
console.error(`UI smoke setup failed: ${error.message}`);
process.exitCode = 2;
});

Powered by TurnKey Linux.