/** * Render the built quest-log card under jsdom and report its location. * * Usage: node render-quest-log.mjs < spec.json * * The spec JSON carries {bundle, url, config, hass, idle_ms?, probe_ms?}; * the card is mounted at the given URL (it resolves its child from the * pathname's last segment), after the optional probe wait one snapshot of * the render is taken mid-wait, then after the optional idle wait stdout * receives one JSON object {location, headline, countdown, html, tiles, * probe, tap} — the final window pathname, the rendered title, the * complete-screen countdown text, the rendered shadow DOM, each quest * card's icon tile, and the probe snapshot when probe_ms was given. With * tap_complete: the harness taps that card's Complete button, * records whether the confirm dialog opened, taps the dialog's Complete, and * reports every hass.callService call as tap: {dialog_opened, service_calls}. * With tap_return: true the harness taps the header's return control and * reports it as returned: {rendered, label} before the final snapshot. * With now: the card's clock is pinned there (argument-less * new Date() and Date.now() return it; the process TZ decides its local * wall time), and with advance_to: [, ...] the clock moves to * each instant in turn and the card's own 60 s clock-tick callbacks fire, * one snapshot per instant reported as ticks (the top-level snapshot is * taken before the clock moves). * The Python panel tests drive this harness so the * assertions run against the exact bundle HACS ships. */ import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { JSDOM } from "jsdom"; const spec = JSON.parse(readFileSync(0, "utf8")); const dom = new JSDOM("", { url: spec.url ?? "http://homeassistant.local/nestquest/ada", pretendToBeVisual: true, }); // Expose the jsdom globals the Lit bundle expects. Node's own intrinsics // (Object, Array, ...) are kept — only what globalThis lacks is copied. for (const key of Object.getOwnPropertyNames(dom.window)) { if (!(key in globalThis)) { try { globalThis[key] = dom.window[key]; } catch { // jsdom getters may throw on access; the card does not need them. } } } globalThis.window = dom.window; // The card navigates by dispatching new Event("location-changed") on this // window; jsdom's EventTarget rejects Node's own global Event class, so the // jsdom Event must shadow it for the return navigation to be delivered. globalThis.Event = dom.window.Event; // Pin the clock: argument-less construction and Date.now() read `pinned`, // every other Date use is the real class. let pinned = spec.now !== undefined ? new Date(spec.now).getTime() : null; if (pinned !== null) { const RealDate = Date; class PinnedDate extends RealDate { constructor(...args) { super(...(args.length === 0 ? [pinned] : args)); } static now() { return pinned; } } globalThis.Date = PinnedDate; dom.window.Date = PinnedDate; } // Record the card's 60 s clock ticks so advance_to can fire them on demand. const clockTicks = []; const realSetInterval = dom.window.setInterval.bind(dom.window); dom.window.setInterval = (callback, ms, ...rest) => { if (ms === 60000) { clockTicks.push(callback); } return realSetInterval(callback, ms, ...rest); }; await import(pathToFileURL(spec.bundle).href); const serviceCalls = []; const hass = { ...spec.hass, callService(domain, service, data) { serviceCalls.push({ domain, service, data }); return Promise.resolve(); }, }; const element = document.createElement("nestquest-quest-log-card"); element.setConfig(spec.config); element.hass = hass; document.body.appendChild(element); await element.updateComplete; let tap = null; if (spec.tap_complete !== undefined) { element.shadowRoot .querySelector(`.quest[data-instance-id="${spec.tap_complete}"] button.complete`) .click(); await element.updateComplete; const confirm = element.shadowRoot.querySelector("button.do-complete"); confirm?.click(); await element.updateComplete; await new Promise((resolve) => setTimeout(resolve, 0)); tap = { dialog_opened: confirm !== null, service_calls: serviceCalls }; } let returned = null; if (spec.tap_return) { const control = element.shadowRoot.querySelector(".header button.return"); returned = { rendered: control !== null, label: control?.textContent?.trim() ?? "", }; control?.click(); await element.updateComplete; } const snapshot = () => ({ location: window.location.pathname, headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "", countdown: element.shadowRoot.querySelector(".complete-countdown")?.textContent?.trim() ?? "", html: element.shadowRoot.innerHTML, // Each quest card's icon tile: the registry glyph it rendered (its // data-icon key and svg fill), its text (an emoji), and whether it shows // the static star (the star's polygon). tiles: [...element.shadowRoot.querySelectorAll(".quest")].map((quest) => { const tile = quest.querySelector(".tile"); const glyph = tile?.querySelector("[data-icon]"); return { id: Number(quest.dataset.instanceId), sealed: quest.classList.contains("sealed"), icon: glyph?.getAttribute("data-icon") ?? null, fill: glyph?.getAttribute("fill") ?? null, paths: tile?.querySelectorAll("path").length ?? 0, text: tile?.textContent?.trim() ?? "", star: Boolean(tile?.querySelector("polygon")), }; }), }); let probe = null; if (spec.probe_ms !== undefined) { await new Promise((resolve) => setTimeout(resolve, spec.probe_ms)); probe = snapshot(); } if (spec.idle_ms !== undefined) { await new Promise((resolve) => setTimeout(resolve, spec.idle_ms)); } const settled = snapshot(); let ticks = null; if (spec.advance_to !== undefined) { ticks = []; for (const instant of spec.advance_to) { pinned = new Date(instant).getTime(); clockTicks.forEach((callback) => callback()); await element.updateComplete; ticks.push(snapshot()); } } const payload = JSON.stringify({ ...settled, probe, tap, returned, ticks }); await new Promise((resolve) => process.stdout.write(payload, () => resolve())); process.exit(0);