383 lines
14 KiB
JavaScript
383 lines
14 KiB
JavaScript
/** Verify the dashboard strategy registers in HA 2026.5+'s frontend.
|
|
*
|
|
* Headless Chromium logs into ha-maint, then evaluates window.customStrategies
|
|
* to confirm our entry is there. Plus tries the actual generate() call to make
|
|
* sure the WS round-trip works.
|
|
*
|
|
* Run after: npm run build && docker restart ha-maint
|
|
* node verify-strategy.mjs
|
|
*/
|
|
import { chromium } from "playwright";
|
|
|
|
const HA = "http://localhost:8125";
|
|
const HA_TOKEN = process.env.HA_TOKEN;
|
|
if (!HA_TOKEN) {
|
|
console.error("Set HA_TOKEN env var (long-lived access token).");
|
|
process.exit(1);
|
|
}
|
|
|
|
// HA expects a *refresh* token in localStorage, not an LLAT. Quick refresh-token
|
|
// dance: hit /auth/login_flow with username/password.
|
|
async function getRefreshToken() {
|
|
async function post(path, body, ctype = "application/json") {
|
|
const data = typeof body === "string" ? body : JSON.stringify(body);
|
|
const r = await fetch(`${HA}${path}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": ctype },
|
|
body: data,
|
|
});
|
|
return await r.json();
|
|
}
|
|
const flow = await post("/auth/login_flow", {
|
|
client_id: `${HA}/`,
|
|
handler: ["homeassistant", null],
|
|
redirect_uri: `${HA}/`,
|
|
});
|
|
const auth = await post(`/auth/login_flow/${flow.flow_id}`, {
|
|
client_id: `${HA}/`,
|
|
username: "dev",
|
|
password: "dev",
|
|
});
|
|
const tokens = await post(
|
|
"/auth/token",
|
|
`grant_type=authorization_code&code=${auth.result}&client_id=${HA}/`,
|
|
"application/x-www-form-urlencoded",
|
|
);
|
|
return tokens.refresh_token;
|
|
}
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
const page = await ctx.newPage();
|
|
const consoleErrors = [];
|
|
page.on("pageerror", (err) => consoleErrors.push(`pageerror: ${err.message}`));
|
|
page.on("console", (msg) => {
|
|
if (msg.type() === "error") consoleErrors.push(`console.error: ${msg.text()}`);
|
|
});
|
|
|
|
const refreshToken = await getRefreshToken();
|
|
await page.goto(HA);
|
|
await page.waitForTimeout(1000);
|
|
await page.evaluate(({ ha, r }) => {
|
|
localStorage.setItem(
|
|
"hassTokens",
|
|
JSON.stringify({
|
|
hassUrl: ha,
|
|
clientId: `${ha}/`,
|
|
refresh_token: r,
|
|
access_token: "",
|
|
token_type: "Bearer",
|
|
expires_in: 1800,
|
|
expires: 0,
|
|
}),
|
|
);
|
|
}, { ha: HA, r: refreshToken });
|
|
await page.goto(HA);
|
|
await page.waitForTimeout(8000); // let frontend extra modules load
|
|
|
|
const result = await page.evaluate(async () => {
|
|
const out = {};
|
|
|
|
// 1. window.customStrategies populated?
|
|
out.customStrategies = (window.customStrategies || []).map((s) => ({
|
|
type: s.type,
|
|
strategyType: s.strategyType,
|
|
name: s.name,
|
|
}));
|
|
|
|
// 2. Custom element registered?
|
|
out.elementRegistered = !!customElements.get(
|
|
"ll-strategy-dashboard-maintenance-supporter",
|
|
);
|
|
|
|
// 3. Try the actual generate() call in ALL group_by modes
|
|
try {
|
|
const StrategyClass = customElements.get(
|
|
"ll-strategy-dashboard-maintenance-supporter",
|
|
);
|
|
const ha = document.querySelector("home-assistant");
|
|
if (StrategyClass?.generate && ha?.hass) {
|
|
for (const mode of ["area", "status", "floor", "due_date"]) {
|
|
const cfg = await StrategyClass.generate(
|
|
{ type: "custom:maintenance-supporter", group_by: mode },
|
|
ha.hass,
|
|
);
|
|
out[`generated_${mode}`] = {
|
|
viewCount: cfg.views?.length ?? 0,
|
|
viewTitles: (cfg.views || []).map((v) => v.title),
|
|
};
|
|
}
|
|
}
|
|
} catch (e) {
|
|
out.generateError = String(e);
|
|
}
|
|
|
|
// 4. Editor element registered + setConfig works + dispatches event
|
|
try {
|
|
out.editorRegistered = !!customElements.get(
|
|
"hui-maintenance-supporter-strategy-editor",
|
|
);
|
|
if (out.editorRegistered) {
|
|
const StrategyClass = customElements.get(
|
|
"ll-strategy-dashboard-maintenance-supporter",
|
|
);
|
|
const editor = await StrategyClass.getConfigElement();
|
|
document.body.appendChild(editor);
|
|
editor.setConfig({
|
|
type: "custom:maintenance-supporter",
|
|
group_by: "status",
|
|
});
|
|
const select = editor.querySelector("select");
|
|
out.editorPreselected = select?.value;
|
|
let dispatchedConfig = null;
|
|
editor.addEventListener("config-changed", (e) => {
|
|
dispatchedConfig = e.detail.config;
|
|
});
|
|
select.value = "due_date";
|
|
select.dispatchEvent(new Event("change"));
|
|
out.editorDispatched = dispatchedConfig;
|
|
editor.remove();
|
|
}
|
|
} catch (e) {
|
|
out.editorError = String(e);
|
|
}
|
|
|
|
// 5. v1.8.0 — Header + Sidebar present on Overview view
|
|
try {
|
|
const StrategyClass = customElements.get(
|
|
"ll-strategy-dashboard-maintenance-supporter",
|
|
);
|
|
const ha = document.querySelector("home-assistant");
|
|
if (StrategyClass?.generate && ha?.hass) {
|
|
const cfg = await StrategyClass.generate(
|
|
{ type: "custom:maintenance-supporter" },
|
|
ha.hass,
|
|
);
|
|
const overview = cfg.views?.find((v) => v.path === "overview");
|
|
out.overviewHeader = overview?.header?.card?.type;
|
|
out.overviewSidebar = overview?.sidebar?.sections?.[0]?.cards?.length ?? 0;
|
|
out.overviewSidebarVisibility =
|
|
overview?.sidebar?.visibility?.[0]?.condition;
|
|
}
|
|
} catch (e) {
|
|
out.overviewError = String(e);
|
|
}
|
|
|
|
// 6. v1.8.0 — Section strategy registered + generates
|
|
try {
|
|
out.sectionStrategyInRegistry = (window.customStrategies || []).some(
|
|
(s) => s.type === "maintenance-supporter-section" && s.strategyType === "section",
|
|
);
|
|
out.sectionElementRegistered = !!customElements.get(
|
|
"ll-strategy-section-maintenance-supporter-section",
|
|
);
|
|
const SectionClass = customElements.get(
|
|
"ll-strategy-section-maintenance-supporter-section",
|
|
);
|
|
const ha = document.querySelector("home-assistant");
|
|
if (SectionClass?.generate && ha?.hass) {
|
|
const sec = await SectionClass.generate(
|
|
{
|
|
type: "custom:maintenance-supporter-section",
|
|
filter_status: ["overdue", "triggered"],
|
|
title: "Hot list",
|
|
},
|
|
ha.hass,
|
|
);
|
|
out.sectionGenerated = {
|
|
type: sec.type,
|
|
cardCount: sec.cards?.length ?? 0,
|
|
firstCardType: sec.cards?.[0]?.type,
|
|
lastCardType: sec.cards?.[sec.cards.length - 1]?.type,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
out.sectionError = String(e);
|
|
}
|
|
|
|
// 7. v1.9.0 — fire-dom-event handler opens dialog IN PLACE (object dialog)
|
|
// rather than navigating. Detect by checking if the dialog element was
|
|
// appended to body and is in "open" state.
|
|
try {
|
|
// Use HA's REAL fire-dom-event shape — the action config is the event
|
|
// detail and our payload sits under `.ll_custom`. Dispatching the payload
|
|
// at the top level (as this verifier used to) tested a shape that never
|
|
// occurs in production and masked issue #69.
|
|
document.dispatchEvent(
|
|
new CustomEvent("ll-custom", {
|
|
detail: {
|
|
action: "fire-dom-event",
|
|
ll_custom: { type: "maintenance-supporter:add-object" },
|
|
},
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
// Give the synchronous mount a tick to settle
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
const dlg = document.body.querySelector("maintenance-object-dialog");
|
|
out.objectDialogMounted = !!dlg;
|
|
out.objectDialogOpen = !!dlg && dlg.shadowRoot?.querySelector("ha-dialog, dialog, .dialog") != null;
|
|
// Close it via internal state if possible (don't leave a dialog open)
|
|
if (dlg) {
|
|
dlg._open = false;
|
|
dlg.requestUpdate?.();
|
|
}
|
|
} catch (e) {
|
|
out.fireDomError = String(e);
|
|
}
|
|
|
|
// 8. v1.9.0 — group_by=calendar generates 4 windowed calendar views
|
|
try {
|
|
const StrategyClass = customElements.get(
|
|
"ll-strategy-dashboard-maintenance-supporter",
|
|
);
|
|
const ha = document.querySelector("home-assistant");
|
|
if (StrategyClass?.generate && ha?.hass) {
|
|
const cfg = await StrategyClass.generate(
|
|
{ type: "custom:maintenance-supporter", group_by: "calendar" },
|
|
ha.hass,
|
|
);
|
|
out.generated_calendar = {
|
|
viewCount: cfg.views?.length ?? 0,
|
|
viewTitles: (cfg.views || []).map((v) => v.title),
|
|
firstCalendarCard: cfg.views?.[1]?.cards?.[0]?.type,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
out.generateCalendarError = String(e);
|
|
}
|
|
|
|
// 9. v1.9.0 — Calendar card element registered + customCards entry present
|
|
try {
|
|
out.calendarCardRegistered = !!customElements.get(
|
|
"maintenance-supporter-calendar-card",
|
|
);
|
|
out.calendarCardInRegistry = (window.customCards || []).some(
|
|
(c) => c.type === "maintenance-supporter-calendar-card",
|
|
);
|
|
} catch (e) {
|
|
out.calendarError = String(e);
|
|
}
|
|
|
|
// 10. v1.9.1 — Calendar card editor: registers, setConfig works, dispatches
|
|
// config-changed when user picks a different window_days option.
|
|
try {
|
|
out.calendarEditorRegistered = !!customElements.get(
|
|
"maintenance-supporter-calendar-card-editor",
|
|
);
|
|
if (out.calendarEditorRegistered) {
|
|
const CardClass = customElements.get(
|
|
"maintenance-supporter-calendar-card",
|
|
);
|
|
const editor = CardClass.getConfigElement();
|
|
document.body.appendChild(editor);
|
|
editor.setConfig({
|
|
type: "custom:maintenance-supporter-calendar-card",
|
|
window_days: 14,
|
|
});
|
|
// Wait for first render
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
const sel = editor.shadowRoot?.querySelector("#window");
|
|
out.calendarEditorPreselected = sel?.value;
|
|
let dispatchedConfig = null;
|
|
editor.addEventListener("config-changed", (e) => {
|
|
dispatchedConfig = e.detail.config;
|
|
});
|
|
sel.value = "365";
|
|
sel.dispatchEvent(new Event("change"));
|
|
out.calendarEditorDispatched = dispatchedConfig;
|
|
editor.remove();
|
|
}
|
|
} catch (e) {
|
|
out.calendarEditorError = String(e);
|
|
}
|
|
|
|
// 4. HA version
|
|
const ha = document.querySelector("home-assistant");
|
|
out.haVersion = ha?.hass?.config?.version;
|
|
|
|
return out;
|
|
});
|
|
|
|
console.log(JSON.stringify(result, null, 2));
|
|
|
|
const ours = result.customStrategies.find(
|
|
(s) => s.type === "maintenance-supporter",
|
|
);
|
|
const okRegister = !!ours;
|
|
const okElement = result.elementRegistered;
|
|
const okArea = (result.generated_area?.viewCount ?? 0) > 0;
|
|
const okStatus = (result.generated_status?.viewCount ?? 0) > 0;
|
|
const okFloor = (result.generated_floor?.viewCount ?? 0) > 0;
|
|
const okDueDate = (result.generated_due_date?.viewCount ?? 0) > 0;
|
|
const okEditor =
|
|
result.editorRegistered === true &&
|
|
result.editorPreselected === "status" &&
|
|
result.editorDispatched?.group_by === "due_date";
|
|
const okHeader = result.overviewHeader === "markdown";
|
|
const okSidebar =
|
|
(result.overviewSidebar ?? 0) > 0 &&
|
|
result.overviewSidebarVisibility === "view_columns";
|
|
const okSection =
|
|
result.sectionStrategyInRegistry === true &&
|
|
result.sectionElementRegistered === true &&
|
|
(result.sectionGenerated?.cardCount ?? 0) >= 2 &&
|
|
result.sectionGenerated?.firstCardType === "heading" &&
|
|
result.sectionGenerated?.lastCardType === "custom:maintenance-supporter-card";
|
|
const okFireDom = result.objectDialogMounted === true;
|
|
const okCalendarMode =
|
|
(result.generated_calendar?.viewCount ?? 0) === 5 && // Overview + 4 windows
|
|
result.generated_calendar?.firstCalendarCard === "custom:maintenance-supporter-calendar-card";
|
|
const okCalendarCard =
|
|
result.calendarCardRegistered === true &&
|
|
result.calendarCardInRegistry === true;
|
|
const okCalendarEditor =
|
|
result.calendarEditorRegistered === true &&
|
|
result.calendarEditorPreselected === "14" &&
|
|
result.calendarEditorDispatched?.window_days === 365;
|
|
|
|
console.log(
|
|
"\n=== SUMMARY ===\n" +
|
|
` HA version: ${result.haVersion}\n` +
|
|
` customStrategies has our entry: ${okRegister ? "✓" : "✗"}\n` +
|
|
` custom element registered: ${okElement ? "✓" : "✗"}\n` +
|
|
` generate(group_by=area): ${okArea ? "✓" : "✗"} (${result.generated_area?.viewCount} views)\n` +
|
|
` generate(group_by=status): ${okStatus ? "✓" : "✗"} (${result.generated_status?.viewCount} views)\n` +
|
|
` generate(group_by=floor): ${okFloor ? "✓" : "✗"} (${result.generated_floor?.viewCount} views)\n` +
|
|
` generate(group_by=due_date): ${okDueDate ? "✓" : "✗"} (${result.generated_due_date?.viewCount} views)\n` +
|
|
` editor registered + works: ${okEditor ? "✓" : "✗"}\n` +
|
|
` Overview has KPI header: ${okHeader ? "✓" : "✗"} (${result.overviewHeader})\n` +
|
|
` Overview has sidebar (large): ${okSidebar ? "✓" : "✗"} (${result.overviewSidebar} cards, visibility=${result.overviewSidebarVisibility})\n` +
|
|
` Section strategy registered+works: ${okSection ? "✓" : "✗"} (${result.sectionGenerated?.cardCount} cards)\n` +
|
|
` fire-dom-event opens in-place: ${okFireDom ? "✓" : "✗"} (object dialog mounted=${result.objectDialogMounted})\n` +
|
|
` generate(group_by=calendar): ${okCalendarMode ? "✓" : "✗"} (${result.generated_calendar?.viewCount} views, first-card=${result.generated_calendar?.firstCalendarCard})\n` +
|
|
` Calendar card registered+listed: ${okCalendarCard ? "✓" : "✗"} (element=${result.calendarCardRegistered}, customCards=${result.calendarCardInRegistry})\n` +
|
|
` Calendar editor + works: ${okCalendarEditor ? "✓" : "✗"} (preselect=${result.calendarEditorPreselected}, dispatched=${result.calendarEditorDispatched?.window_days})`,
|
|
);
|
|
|
|
if (consoleErrors.length) {
|
|
console.log("\n=== BROWSER CONSOLE ERRORS ===");
|
|
consoleErrors.slice(0, 10).forEach((e) => console.log(` ${e.substring(0, 300)}`));
|
|
}
|
|
|
|
await browser.close();
|
|
process.exit(
|
|
okRegister &&
|
|
okElement &&
|
|
okArea &&
|
|
okStatus &&
|
|
okFloor &&
|
|
okDueDate &&
|
|
okEditor &&
|
|
okHeader &&
|
|
okSidebar &&
|
|
okSection &&
|
|
okFireDom &&
|
|
okCalendarMode &&
|
|
okCalendarCard &&
|
|
okCalendarEditor
|
|
? 0
|
|
: 1,
|
|
);
|