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