Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/verify-strategy.mjs
T
2026-07-08 10:43:39 -04:00

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,
);