/** * Capture README screenshots for the Maintenance Supporter integration. * * Prerequisites: * docker compose up -d # ha-maint * docker compose --profile testing up -d # playwright * python scripts/setup_demo.py && python scripts/seed_history.py * docker compose restart homeassistant-dev * * Usage: * cd custom_components/maintenance_supporter/frontend-src * node capture-readme-screenshots.mjs * * Or with explicit refresh token: * HA_REFRESH_TOKEN= node capture-readme-screenshots.mjs */ import { chromium } from "playwright"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const HA = "http://homeassistant-dev:8123"; const OUTPUT = path.resolve(__dirname, "../../../docs/images"); // --------------------------------------------------------------------------- // Access token: env var or auto-extract from docker/.env // --------------------------------------------------------------------------- function getAccessToken() { if (process.env.HA_TOKEN) return process.env.HA_TOKEN; const envPath = path.resolve(__dirname, "../../../docker/.env"); try { const lines = fs.readFileSync(envPath, "utf8").split("\n"); for (const line of lines) { if (line.startsWith("HA_TOKEN=")) return line.split("=")[1].trim(); } } catch { /* fall through */ } console.error("Set HA_TOKEN or ensure docker/.env exists with HA_TOKEN=..."); process.exit(1); } const ACCESS_TOKEN = getAccessToken(); fs.mkdirSync(OUTPUT, { recursive: true }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function panelJS() { return ` var ha = document.querySelector('home-assistant'); var main = ha && ha.shadowRoot && ha.shadowRoot.querySelector('home-assistant-main'); var drawer = main && main.shadowRoot && main.shadowRoot.querySelector('ha-drawer'); var resolver = drawer && drawer.querySelector('partial-panel-resolver'); var custom = resolver && resolver.querySelector('ha-panel-custom'); var p = custom && custom.querySelector('maintenance-supporter-panel'); `; } async function login(page) { await page.goto(HA); await page.waitForTimeout(1000); await page.evaluate((token) => { localStorage.setItem("hassTokens", JSON.stringify({ hassUrl: "http://homeassistant-dev:8123", clientId: "http://homeassistant-dev:8123/", refresh_token: "", access_token: token, token_type: "Bearer", expires_in: 86400, expires: Date.now() + 86400000, })); }, ACCESS_TOKEN); await page.goto(HA + "/maintenance-supporter"); await page.waitForTimeout(8000); } /** Returns true if page is still alive, false if it crashed. */ async function setEnglishDark(page) { try { await page.evaluate(async () => { const ha = document.querySelector("home-assistant"); const hass = ha && (ha.__hass || ha.hass); if (hass && hass.connection) { await hass.connection.sendMessagePromise({ type: "frontend/set_user_data", key: "language", value: { language: "en", number_format: "language" }, }); await hass.connection.sendMessagePromise({ type: "frontend/set_user_data", key: "core", value: { selectedTheme: { theme: "default", dark: true } }, }); } }); await page.goto(HA + "/maintenance-supporter"); await page.waitForTimeout(8000); return true; } catch { console.log(" (language/theme change crashed, will recover)"); return false; } } async function getObjectData(page, maxWait = 30000) { const start = Date.now(); while (Date.now() - start < maxWait) { const raw = await page.evaluate(`{ ${panelJS()} var result = []; if (p && p._objects) { for (var i = 0; i < p._objects.length; i++) { var obj = p._objects[i]; var tasks = []; if (obj.tasks) for (var j = 0; j < obj.tasks.length; j++) { tasks.push({ id: obj.tasks[j].id, name: obj.tasks[j].name, checklist: obj.tasks[j].checklist || [], adaptive: !!(obj.tasks[j].adaptive_config && obj.tasks[j].adaptive_config.enabled) }); } result.push({ entry_id: obj.entry_id, name: obj.object.name, tasks: tasks }); } } JSON.stringify(result); }`); const data = JSON.parse(raw); if (data.length > 0) return data; await page.waitForTimeout(1000); } throw new Error("Timed out waiting for panel objects to load"); } function find(objects, objName, taskName) { const obj = objects.find((o) => o.name === objName); if (!obj) throw new Error(`Object '${objName}' not found`); if (!taskName) return { entryId: obj.entry_id, obj }; const task = obj.tasks.find((t) => t.name === taskName); if (!task) throw new Error(`Task '${taskName}' not found in '${objName}'`); return { entryId: obj.entry_id, taskId: task.id, task, obj }; } async function shot(page, name) { const filePath = path.join(OUTPUT, name); await page.screenshot({ path: filePath }); console.log(` ${name}`); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- const browser = await chromium.connect("ws://localhost:3000"); console.log("Connected to Playwright server\n"); // ======================== DESKTOP (1280×900) ======================== const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, locale: "en", colorScheme: "dark" }); let page = await ctx.newPage(); await login(page); const langOk = await setEnglishDark(page); if (!langOk) { // Language change crashed the page — recreate and re-login try { await page.close(); } catch { /* already dead */ } page = await ctx.newPage(); await login(page); } const objects = await getObjectData(page); console.log(`Found ${objects.length} objects: ${objects.map((o) => o.name).join(", ")}\n`); // 1. Overview console.log("Desktop screenshots:"); await shot(page, "overview.png"); // 2. Object detail — Electric Car (most tasks) const ev = find(objects, "Electric Car"); await page.evaluate(`{ ${panelJS()} if (p) p._showObject('${ev.entryId}'); }`); await page.waitForTimeout(2000); await shot(page, "object-detail.png"); // 3. Task detail — HVAC Filter Replacement (threshold trigger with sparkline) const hvac = find(objects, "HVAC System", "Filter Replacement"); await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${hvac.entryId}', '${hvac.taskId}'); }`); await page.waitForTimeout(5000); await shot(page, "task-detail.png"); // 3b. Multi-entity trigger — Electric Car Tire Pressure Check (4 sensors, threshold < 2.0 bar) const tire = find(objects, "Electric Car", "Tire Pressure Check"); await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${tire.entryId}', '${tire.taskId}'); }`); await page.waitForTimeout(5000); await shot(page, "multi-entity-trigger.png"); // 3c. Compound trigger — Water Filter System (OR: threshold + counter) const wf = find(objects, "Water Filter System", "Cartridge Replacement"); await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${wf.entryId}', '${wf.taskId}'); }`); await page.waitForTimeout(5000); await shot(page, "compound-trigger.png"); // 4. Task history — Washing Machine Drum Cleaning (8 entries) const wm = find(objects, "Washing Machine", "Drum Cleaning"); await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${wm.entryId}', '${wm.taskId}'); }`); await page.waitForTimeout(2000); // Switch to history tab await page.evaluate(`{ ${panelJS()} if (p) { var tabs = p.shadowRoot && p.shadowRoot.querySelectorAll('.tab'); if (tabs && tabs.length > 1) tabs[1].click(); } }`); await page.waitForTimeout(1500); await shot(page, "task-history.png"); // 5. Complete dialog — use a task with checklist if possible const pool = find(objects, "Swimming Pool", "pH Test"); await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${pool.entryId}', '${pool.taskId}'); }`); await page.waitForTimeout(2000); await page.evaluate(`{ ${panelJS()} if (p) p._openCompleteDialog('${pool.entryId}', '${pool.taskId}', 'pH Test', ${JSON.stringify(pool.task.checklist)}, ${pool.task.adaptive}); }`); await page.waitForTimeout(1500); await shot(page, "complete-dialog.png"); // Close dialog await page.keyboard.press("Escape"); await page.waitForTimeout(500); // 6. QR dialog await page.evaluate(`{ ${panelJS()} if (p) p._openQrForTask('${hvac.entryId}', '${hvac.taskId}', 'HVAC System', 'Filter Replacement'); }`); await page.waitForTimeout(3000); await shot(page, "qr-dialog.png"); await page.keyboard.press("Escape"); await page.waitForTimeout(500); // 7. Config flow — integration page await page.goto(HA + "/config/integrations/integration/maintenance_supporter"); await page.waitForTimeout(4000); await shot(page, "config-flow.png"); // 8. Lovelace card — create temp dashboard via REST then screenshot try { // Create dashboard via lovelace WS — must return a promise const created = await page.evaluate(() => { return new Promise((resolve, reject) => { const ha = document.querySelector("home-assistant"); const hass = ha && (ha.__hass || ha.hass); if (!hass || !hass.connection) { reject(new Error("no hass")); return; } hass.connection.sendMessagePromise({ type: "lovelace/dashboards/create", url_path: "maintenance-demo", title: "Maintenance Demo", mode: "storage", }).then(() => resolve(true)).catch((e) => { // Dashboard may already exist, try saving config directly resolve(true); }); }); }); // Save config to the dashboard await page.evaluate(() => { return new Promise((resolve) => { const ha = document.querySelector("home-assistant"); const hass = ha && (ha.__hass || ha.hass); if (!hass) { resolve(); return; } hass.connection.sendMessagePromise({ type: "lovelace/config/save", url_path: "maintenance-demo", config: { title: "Maintenance Demo", views: [{ title: "Overview", cards: [{ type: "custom:maintenance-supporter-card", title: "Maintenance Overview", show_header: true, show_actions: true, }], }], }, }).then(() => resolve()).catch(() => resolve()); }); }); await page.goto(HA + "/maintenance-demo/0"); await page.waitForTimeout(6000); await shot(page, "lovelace-card.png"); // Cleanup await page.evaluate(() => { const ha = document.querySelector("home-assistant"); const hass = ha && (ha.__hass || ha.hass); if (hass) { hass.connection.sendMessagePromise({ type: "lovelace/dashboards/delete", dashboard_id: "maintenance-demo", }).catch(() => {}); } }); } catch (e) { console.log(" lovelace-card.png SKIPPED:", e.message); } // 9. Calendar — show month view with maintenance events await page.goto(HA + "/calendar"); await page.waitForTimeout(5000); // Enable the maintenance calendar checkbox try { // Pre-select the maintenance calendar in localStorage so the panel shows it await page.evaluate(() => { // HA stores selected calendars in hass-panel-calendar-selected per-user // Try to find and click the checkbox through shadow DOM const ha = document.querySelector("home-assistant"); const main = ha?.shadowRoot?.querySelector("home-assistant-main"); const drawer = main?.shadowRoot?.querySelector("ha-drawer"); const resolver = drawer?.querySelector("partial-panel-resolver"); const calPanel = resolver?.querySelector("ha-panel-calendar"); const sr = calPanel?.shadowRoot; if (!sr) return "no-shadow-root"; // Try ha-check-list-item or ha-checkbox or mwc-checkbox const checkItems = sr.querySelectorAll("ha-check-list-item, ha-checkbox-list-item"); for (const item of checkItems) { if (item.textContent?.includes("Maintenance")) { if (!item.selected && !item.checked) item.click(); return "clicked"; } } // Try ha-calendar-list-item approach const calList = sr.querySelector("ha-sidebar-calendars, .calendars"); if (calList) { const labels = calList.querySelectorAll("label, li, div"); for (const lbl of labels) { if (lbl.textContent?.includes("Maintenance")) { const cb = lbl.querySelector("input[type=checkbox], ha-checkbox"); if (cb && !cb.checked) cb.click(); return "clicked-inner"; } } } // Dump what we find for debugging return "items:" + sr.innerHTML.substring(0, 500); }).then(r => console.log(" calendar checkbox:", r)); await page.waitForTimeout(3000); } catch (e) { console.log(" calendar checkbox error:", e.message); } // Advance the calendar by one month — with a freshly-seeded demo setup most // tasks' next_due dates are 30-90 days out, so the default "current month" // view typically renders empty. Attempt a pixel-click on the "Next" chevron // in HA's calendar toolbar (approx. (650, 85) at 1280×900); fall through // quietly if the click misses. If the resulting screenshot lands in an // empty month, prefer keeping the previously committed calendar.png over // overwriting it — `git checkout HEAD -- docs/images/calendar.png`. try { await page.mouse.click(650, 85); await page.waitForTimeout(2500); } catch (e) { console.log(" calendar next-month error:", e.message); } await shot(page, "calendar.png"); // 10. Sensor attributes — Developer Tools States filtered to show one entity with all attributes await page.goto(HA + "/developer-tools/state"); await page.waitForTimeout(4000); try { // Filter entities by typing into the search field // The filter input is deep in shadow DOM; use evaluate to find and focus it await page.evaluate(() => { const ha = document.querySelector("home-assistant"); const main = ha && ha.shadowRoot && ha.shadowRoot.querySelector("home-assistant-main"); const drawer = main && main.shadowRoot && main.shadowRoot.querySelector("ha-drawer"); const resolver = drawer && drawer.querySelector("partial-panel-resolver"); const devTools = resolver && resolver.querySelector("ha-panel-developer-tools"); if (!devTools || !devTools.shadowRoot) return; const statesPanel = devTools.shadowRoot.querySelector("developer-tools-state"); if (!statesPanel || !statesPanel.shadowRoot) return; // Find the entity filter input (search field in the table header) const searchInputs = statesPanel.shadowRoot.querySelectorAll("search-input, ha-search-input, input[type=search]"); // Also try generic inputs const allInputs = statesPanel.shadowRoot.querySelectorAll("input"); const target = searchInputs[0] || allInputs[0]; if (target) { target.focus(); target.value = "sensor.hvac_system"; target.dispatchEvent(new Event("input", { bubbles: true })); target.dispatchEvent(new Event("change", { bubbles: true })); } }); await page.waitForTimeout(1000); // Fallback: use keyboard to type into whatever is focused await page.keyboard.type("sensor.hvac_system", { delay: 30 }); await page.waitForTimeout(2000); } catch { /* filter may not work */ } await shot(page, "entity-attributes.png"); await ctx.close(); // ======================== MOBILE (375×812) ======================== console.log("\nMobile screenshots:"); try { const mCtx = await browser.newContext({ viewport: { width: 375, height: 812 }, locale: "en", isMobile: true, colorScheme: "dark" }); const mPage = await mCtx.newPage(); await login(mPage); // Skip setEnglishDark for mobile — sendMessage can crash mobile contexts const mObjects = await getObjectData(mPage); // 11. Mobile overview await shot(mPage, "mobile-overview.png"); // 12. Mobile task detail const mHvac = find(mObjects, "HVAC System", "Filter Replacement"); await mPage.evaluate(`{ ${panelJS()} if (p) p._showTask('${mHvac.entryId}', '${mHvac.taskId}'); }`); await mPage.waitForTimeout(2000); await shot(mPage, "mobile-task.png"); await mCtx.close(); } catch (e) { console.log(" mobile screenshots SKIPPED:", e.message); } // ======================== NOTIFICATION MOCKUP (375×812) ======================== // Rendered as pure HTML/CSS — no HA login needed, own context for stability console.log("\nNotification mockup:"); const nCtx = await browser.newContext({ viewport: { width: 375, height: 812 }, locale: "en" }); const notifPage = await nCtx.newPage(); await notifPage.setContent(`
9:41
Saturday, March 7
HOME ASSISTANT now
Maintenance Overdue!
Oil Change for Family Car is 3 day(s) overdue!
\u2705 Complete
\u23ed\ufe0f Skip
\ud83d\udca4 Snooze
`, { waitUntil: "networkidle" }); await notifPage.waitForTimeout(500); await shot(notifPage, "notification-actions.png"); await notifPage.close(); await nCtx.close(); await browser.close(); console.log(`\nAll screenshots saved to ${OUTPUT}`);