import { mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { launch, checkAll, finish } from './serve.mjs'; const visualArtifacts = new URL('../artifacts/zigbee-topology-798/', import.meta.url); mkdirSync(visualArtifacts, { recursive: true }); const { page, browser } = await launch(); const result = await page.evaluate(async () => { const card = window.__card; const root = () => card.shadowRoot || card.renderRoot; const wait = (predicate, label, timeout = 5000) => new Promise((resolve, reject) => { const started = performance.now(); const tick = () => { if (predicate()) return resolve(); if (performance.now() - started > timeout) return reject(new Error(`topology smoke timeout: ${label}`)); setTimeout(tick, 20); }; tick(); }); const mouse = (type, relatedTarget = null) => new PointerEvent(type, { pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200, relatedTarget, }); const out = {}; await card.updateComplete; out.defaultOff = !root().querySelector('hp-zigbee-topology-overlay'); out.offHasNoLazyChunk = !performance.getEntriesByType('resource') .some((entry) => /zigbee-topology-runtime|hp-zigbee-topology-overlay/.test(entry.name)); let zhaCalls = 0; let pendingConfigReads = 0; window.__zigbeeSmokeFetchCount = () => zhaCalls; const originalHass = card.hass; card.hass = { ...originalHass, callWS: async (message) => { if (message.type !== 'zha/devices') { const configRead = message.type === 'houseplan/config/get'; if (configRead) pendingConfigReads++; try { return await originalHass.callWS(message); } finally { if (configRead) pendingConfigReads--; } } zhaCalls++; return [ { ieee: '00124b0000000001', nwk: 1, device_reg_id: 'd_light1', device_type: 'Router', routes: [{ dest_nwk: 0, next_hop: 0, route_status: 'Active' }], neighbors: [ { ieee: '00124b0000000002', relationship: 'Parent' }, { ieee: '00124b0000000003', lqi: 80, relationship: 'Child' }, { ieee: '00124b0000000005', lqi: 128, relationship: 'Child' }, { ieee: '00124b0000000007', lqi: 255, relationship: 'Sibling' }, ] }, { ieee: '00124b0000000002', nwk: 0, device_reg_id: 'd_lamp', device_type: 'Coordinator', neighbors: [ { ieee: '00124b0000000001', relationship: 'Child' }, { ieee: '00124b0000000006', lqi: 140, relationship: 'Child' }, ] }, { ieee: '00124b0000000003', nwk: 3, device_reg_id: 'd_mower', device_type: 'EndDevice', neighbors: [ { ieee: '00124b0000000001', lqi: 75, relationship: 'Parent' }, ] }, { ieee: '00124b0000000004', nwk: 4, device_reg_id: 'd_temp', device_type: 'EndDevice', neighbors: [ { ieee: '00124b0000000006', lqi: 20, relationship: 'Parent' }, ] }, { ieee: '00124b0000000005', nwk: 5, device_reg_id: 'd_tv', device_type: 'EndDevice', neighbors: [ { ieee: '00124b0000000001', lqi: 128, relationship: 'Parent' }, ] }, { ieee: '00124b0000000006', nwk: 6, device_reg_id: 'not_on_plan', device_type: 'Router', name: 'Hallway & "target"', routes: [{ dest_nwk: '0x0000', next_hop: '0x0000', route_status: 'Active' }], neighbors: [ { ieee: '00124b0000000002', lqi: 130, relationship: 'Parent' }, { ieee: '00124b0000000004', lqi: 25, relationship: 'Child' }, ] }, { ieee: '00124b0000000007', nwk: 7, device_reg_id: 'd_kettle', device_type: 'EndDevice', neighbors: [ { ieee: '00124b0000000001', lqi: 255, relationship: 'Sibling' }, ] }, ]; } }; await card._ensureEditorRuntime(); card._openSettingsDialog(); await card.updateComplete; let settings = root().querySelector('hp-zigbee-topology-settings'); await wait(() => !!settings?.shadowRoot, 'settings mounted'); settings._emit({ enabled: true, z2mBaseTopics: [] }); await card.updateComplete; await card._saveSettingsDialog(); await card.updateComplete; await wait(() => !!root().querySelector('hp-zigbee-topology-overlay'), 'overlay mounted'); out.settingPersists = card._serverCfg.settings.zigbee_topology?.enabled === true; out.enableDoesNotFetch = zhaCalls === 0; card._openSettingsDialog(); await card.updateComplete; settings = root().querySelector('hp-zigbee-topology-settings'); await wait(() => !!settings?.shadowRoot?.querySelector('button'), 'settings button'); await settings._readZha(); await wait(() => settings._snapshot?.states?.zha?.phase === 'ready', 'ZHA ready'); // #800: this fixture intentionally has the old integration's config/get. // backend_required may be cached from the overlay's earlier failed check. // Reopening settings legitimately checks again. Drain the actual in-flight // transport and its queued runtime/Lit notifications before the isolated age // fixture; checking the old error string alone is not a completion barrier. await wait(() => settings._snapshot?.backendError === 'backend_required' && pendingConfigReads === 0, 'old backend capability reads settled'); await new Promise(resolve => setTimeout(resolve, 0)); await settings.updateComplete; out.oldBackendStillAllowsZha = settings._snapshot.states.zha.phase === 'ready'; out.explicitZhaRead = zhaCalls === 1; const settingsSnapshot = settings._snapshot; const renderedProviderStatus = () => [...settings.shadowRoot.querySelectorAll('.status,.hpf-actions .hpf-hint')] .map((node) => node.textContent).find((value) => /Received /.test(value)) || ''; const ageFixtureTime = Date.now() - 60 * 60 * 1000 + 300; settings._acceptSnapshot({ ...settingsSnapshot, states: { ...settingsSnapshot.states, zha: { phase: 'ready', obtainedAt: ageFixtureTime }, } }); await settings.updateComplete; const initiallyFreshSettings = !/stale/i.test(renderedProviderStatus()); await wait(() => /stale/i.test(renderedProviderStatus()), 'mounted settings snapshot ages'); out.settingsAgeTimerWithoutRefetch = initiallyFreshSettings && zhaCalls === 1 && settings._snapshot.states.zha.obtainedAt === ageFixtureTime; settings._acceptSnapshot(settingsSnapshot); await settings.updateComplete; card._settingsDialog = null; card.requestUpdate(); await card.updateComplete; out.settingsDisconnectClearsTimer = !settings.isConnected && settings._staleTimer === undefined; const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const centre = (element) => { const rect = element.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; }; const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); const isoTopologyGeometry = async () => { const sourceMarker = root().querySelector('.dev[data-id="d_light1"]'); const neighborMarker = root().querySelector('.dev[data-id="d_lamp"]'); sourceMarker.dispatchEvent(mouse('pointerover')); await wait(() => root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot ?.querySelector('line[data-direction="toward-neighbor"]'), 'Iso local route'); const topology = root().querySelector('hp-zigbee-topology-overlay'); await topology.updateComplete; await frame(); const svg = topology.shadowRoot.querySelector('[data-hp="zigbee-topology-lines"]'); const line = topology.shadowRoot.querySelector('line[data-direction="toward-neighbor"]'); const halo = topology.shadowRoot.querySelector('[data-hp="zigbee-topology-neighbor"]'); const matrix = svg?.getScreenCTM(); const projected = (x, y) => new DOMPoint(Number(x), Number(y)).matrixTransform(matrix); const lineStart = projected(line?.getAttribute('x1'), line?.getAttribute('y1')); const lineEnd = projected(line?.getAttribute('x2'), line?.getAttribute('y2')); const sourceCentre = centre(sourceMarker); const neighborCentre = centre(neighborMarker); const haloCentre = centre(halo); const raised = [sourceMarker, neighborMarker].every((marker) => { const floor = marker.getAttribute('data-hp-iso-floor')?.split(',').map(Number); const visual = marker.getAttribute('data-hp-iso-visual')?.split(',').map(Number); return marker.getAttribute('data-hp-iso-overlay-kind') === 'device' && floor?.length === 2 && visual?.length === 2 && floor.every(Number.isFinite) && visual.every(Number.isFinite) && Math.hypot(floor[0] - visual[0], floor[1] - visual[1]) > 0.1; }); return { sourceCentre, neighborCentre, raised, aligned: !!matrix && distance(lineStart, sourceCentre) <= 1 && distance(lineEnd, neighborCentre) <= 1 && distance(haloCentre, neighborCentre) <= 1, }; }; const isoConfigSpace = card._serverCfg.spaces.find((space) => space.id === 'f1'); isoConfigSpace.settings = { ...(isoConfigSpace.settings || {}), show_borders: true, show_names: true, }; card._cfgEpoch++; card.requestUpdate(); await card.updateComplete; await window.__hpHarnessProjection(card, 'iso'); await window.__hpEnsureHarnessIsoRuntime(card); await wait(() => !!root().querySelector('[data-hp="iso-walls"]'), 'Iso walls'); const isoBeforePanZoom = await isoTopologyGeometry(); out.isoMarkersRaised = isoBeforePanZoom.raised; out.isoTopologyUsesRaisedDomCentres = isoBeforePanZoom.aligned; card._applyView(Math.min(2, Math.max(1.25, card._zoom * 1.45)), 0.24, 0.72); card.requestUpdate(); await card.updateComplete; await frame(); const currentSource = root().querySelector('.dev[data-id="d_light1"]'); currentSource.dispatchEvent(mouse('pointerout', root().querySelector('.stage'))); await wait(() => !root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot ?.querySelector('line'), 'Iso route cleared after pan/zoom'); const isoAfterPanZoom = await isoTopologyGeometry(); out.isoTopologyTracksRaisedDomCentresAfterPanZoom = isoAfterPanZoom.aligned; out.isoPanZoomActuallyMovesMarker = distance( isoBeforePanZoom.sourceCentre, isoAfterPanZoom.sourceCentre, ) > 1; currentSource.dispatchEvent(mouse('pointerout', root().querySelector('.stage'))); await window.__hpHarnessProjection(card, 'flat'); await card.updateComplete; await frame(); const source = root().querySelector('.dev[data-id="d_light1"]'); const unknownNeighbor = root().querySelector('.dev[data-id="d_lamp"]'); const knownNeighbor = root().querySelector('.dev[data-id="d_tv"]'); const unrelated = root().querySelector('.dev[data-id="d_temp"]'); const roomLabel = root().querySelector('.roomlabel'); source.style.left = '20%'; source.style.top = '36%'; unknownNeighbor.style.left = '80%'; unknownNeighbor.style.top = '36%'; knownNeighbor.style.left = '80%'; knownNeighbor.style.top = '68%'; unrelated.style.left = '50%'; unrelated.style.top = '36%'; if (roomLabel) { roomLabel.style.left = '65%'; roomLabel.style.top = '36%'; } source.dispatchEvent(mouse('pointerover')); await wait(() => root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot ?.querySelector('[data-hp="zigbee-topology-lines"]'), 'local route'); const overlay = root().querySelector('hp-zigbee-topology-overlay'); await overlay.updateComplete; const devlayer = root().querySelector('.devlayer'); const endpoints = [...devlayer.querySelectorAll('[data-hp-zigbee-topology-endpoint]')] .map((marker) => marker.dataset.id).sort(); const overlayLayer = Number.parseInt(getComputedStyle(overlay.shadowRoot.querySelector('svg')).zIndex, 10); const unrelatedFloorLayer = Number.parseInt(getComputedStyle(unrelated, '::before').zIndex, 10); const unrelatedShellLayer = Number.parseInt( getComputedStyle(unrelated.querySelector('.device-shell')).zIndex, 10, ); const sourceLayer = Number.parseInt(getComputedStyle(source).zIndex, 10); out.incidentOnly = overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-line"]').length === 2 && overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-neighbor"]').length === 2; out.layerContract = overlay.parentElement === devlayer && !overlay.hasAttribute('data-hp-live-layer') && getComputedStyle(unrelated).zIndex === 'auto' && overlayLayer > unrelatedFloorLayer && overlayLayer > unrelatedShellLayer && sourceLayer > overlayLayer && (!roomLabel || overlayLayer > Number.parseInt(getComputedStyle(roomLabel).zIndex, 10)); out.exactEndpoints = endpoints.join(',') === 'd_lamp,d_light1,d_tv' && !unrelated.hasAttribute('data-hp-zigbee-topology-endpoint'); const routeLine = overlay.shadowRoot.querySelector( 'line[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]', ); const routeArrow = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]'); const lineBox = routeLine?.getBoundingClientRect(); const arrowBox = routeArrow?.getBoundingClientRect(); out.localRouteArrow = !!routeLine && routeArrow?.getAttribute('data-direction') === 'toward-neighbor' && !!lineBox && !!arrowBox && arrowBox.width > 3 && arrowBox.height > 3; out.onlyConfirmedArrows = overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-arrow"]').length === 2 && !overlay.shadowRoot.querySelector('line[data-direction="none"]') && !root().querySelector('.dev[data-id="d_kettle"]').hasAttribute('data-hp-zigbee-topology-endpoint'); out.crossSpaceCount = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-remote"]') ?.textContent.trim() === '+1 in other spaces'; out.pointerTransparent = getComputedStyle(overlay).pointerEvents === 'none' && [...overlay.shadowRoot.querySelectorAll('svg,line,polygon,.halo,.remote,.parent-bubble,.route-status')] .every((node) => getComputedStyle(node).pointerEvents === 'none'); const unrelatedRect = unrelated.getBoundingClientRect(); const hitTarget = root().elementFromPoint( unrelatedRect.left + unrelatedRect.width / 2, unrelatedRect.top + unrelatedRect.height / 2, ); let clickReachedMarker = false; unrelated.addEventListener('click', (event) => { clickReachedMarker = true; event.preventDefault(); event.stopImmediatePropagation(); }, { capture: true, once: true }); hitTarget?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); out.pointerHitTarget = !!hitTarget && unrelated.contains(hitTarget) && clickReachedMarker; const casing = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-line-casing"]'); const unknownCore = overlay.shadowRoot.querySelector( '[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]', ); const knownCore = overlay.shadowRoot.querySelector( '[data-hp="zigbee-topology-line"][data-direction="toward-origin"]', ); out.unknownCasingContract = !!casing && !!unknownCore && !!knownCore && overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-line-casing"]').length === 1 && getComputedStyle(casing).stroke === 'rgb(0, 0, 0)' && Math.abs(Number.parseFloat(getComputedStyle(casing).strokeWidth) - (2 + 2 * card._zoom)) < 0.001 && Number.parseFloat(getComputedStyle(unknownCore).strokeWidth) === 2 && getComputedStyle(unknownCore).stroke === 'rgb(145, 155, 165)' && casing.getAttribute('x1') === unknownCore.getAttribute('x1') && casing.getAttribute('y1') === unknownCore.getAttribute('y1') && casing.getAttribute('x2') === unknownCore.getAttribute('x2') && casing.getAttribute('y2') === unknownCore.getAttribute('y2') && getComputedStyle(casing).strokeLinecap === getComputedStyle(unknownCore).strokeLinecap && getComputedStyle(casing).vectorEffect === 'non-scaling-stroke'; out.unknownArrowOutline = !!routeArrow && getComputedStyle(routeArrow).fill === 'rgb(145, 155, 165)' && getComputedStyle(routeArrow).stroke === 'rgb(0, 0, 0)' && Math.abs(Number.parseFloat(getComputedStyle(routeArrow).strokeWidth) - 2 * card._zoom) < 0.001 && getComputedStyle(routeArrow).vectorEffect === 'non-scaling-stroke'; out.allLinksSolid = [...overlay.shadowRoot.querySelectorAll('line,polygon')] .every((node) => getComputedStyle(node).strokeDasharray === 'none'); out.knownLineArrowMatch = getComputedStyle(knownCore).stroke === 'rgb(255, 255, 0)' && getComputedStyle(overlay.shadowRoot.querySelector( '[data-hp="zigbee-topology-arrow"][data-direction="toward-origin"]', )).fill === getComputedStyle(knownCore).stroke; const oldNeighbor = unknownNeighbor; const replacementNeighbor = oldNeighbor.cloneNode(true); oldNeighbor.replaceWith(replacementNeighbor); await wait(() => replacementNeighbor.hasAttribute('data-hp-zigbee-topology-endpoint'), 'endpoint ownership transferred to replacement'); out.domReplacementTransfers = !oldNeighbor.hasAttribute('data-hp-zigbee-topology-endpoint') && replacementNeighbor.hasAttribute('data-hp-zigbee-topology-endpoint'); replacementNeighbor.replaceWith(oldNeighbor); await wait(() => oldNeighbor.hasAttribute('data-hp-zigbee-topology-endpoint'), 'endpoint ownership returned to original marker'); const runtimeBeforeInvalidation = overlay._runtime; window.__zigbeeSmokeBaseline = runtimeBeforeInvalidation; overlay._acceptRuntime({ revision: runtimeBeforeInvalidation.revision + 1, topologies: [], states: runtimeBeforeInvalidation.states, }); await overlay.updateComplete; await wait(() => !root().querySelector('[data-hp-zigbee-topology-endpoint]'), 'runtime invalidation clears endpoint ownership'); out.runtimeInvalidationClears = !overlay.shadowRoot.querySelector('line') && !root().querySelector('[data-hp-zigbee-topology-endpoint]'); overlay._acceptRuntime(runtimeBeforeInvalidation); await overlay.updateComplete; await wait(() => overlay.shadowRoot.querySelector('line'), 'route restored after runtime invalidation'); const noRoute = root().querySelector('.dev[data-id="d_kettle"]'); noRoute.dispatchEvent(mouse('pointerover')); await wait(() => overlay.shadowRoot.querySelector('.route-status'), 'unknown route label'); out.unknownRouteHasNoInventedLine = !overlay.shadowRoot.querySelector('line,polygon') && /no route data/i.test(overlay.shadowRoot.querySelector('.route-status').textContent) && !noRoute.hasAttribute('data-hp-zigbee-topology-endpoint'); unknownNeighbor.dispatchEvent(mouse('pointerover')); await wait(() => overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]'), 'coordinator incoming route'); out.coordinatorHasOnlyIncoming = !/no route data/i.test( overlay.shadowRoot.querySelector('.route-status')?.textContent || '', ) && !overlay.shadowRoot.querySelector('[data-direction="toward-neighbor"]') && !!overlay.shadowRoot.querySelector('[data-direction="toward-origin"]'); card._commitSpace('garden', true); card.requestUpdate(); await card.updateComplete; await wait(() => root().querySelector('.dev[data-id="d_mower"]'), 'garden marker'); out.spaceChangeClears = !root().querySelector('[data-hp-zigbee-topology-endpoint]'); const remoteChild = root().querySelector('.dev[data-id="d_mower"]'); remoteChild.dispatchEvent(mouse('pointerover')); await wait(() => root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot ?.querySelector('[data-hp="zigbee-topology-parent-bubble"]'), 'remote parent bubble'); let activeOverlay = root().querySelector('hp-zigbee-topology-overlay'); const firstTitle = card._serverCfg.spaces.find((space) => space.id === 'f1')?.title; const remoteParentName = card.hass.devices.d_light1.name_by_user || card.hass.devices.d_light1.name; out.remoteParentBubble = activeOverlay.shadowRoot .querySelector('[data-hp="zigbee-topology-parent-bubble"]')?.textContent.trim() === `${firstTitle} (${remoteParentName})` && activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-parent-bubble"]') ?.getAttribute('data-kind') === 'remote-space' && !!activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-parent-arrow"]') && !activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-remote"]'); card._commitSpace('f1', true); card.requestUpdate(); await card.updateComplete; await wait(() => root().querySelector('.dev[data-id="d_temp"]'), 'temperature marker'); const temperature = root().querySelector('.dev[data-id="d_temp"]'); temperature.dispatchEvent(mouse('pointerover')); await wait(() => root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot ?.querySelector('[data-kind="unplaced-device"]'), 'unplaced device bubble'); activeOverlay = root().querySelector('hp-zigbee-topology-overlay'); const missingParentBubble = activeOverlay.shadowRoot.querySelector('[data-kind="unplaced-device"]'); out.unplacedDeviceBubble = missingParentBubble?.textContent.trim() === 'Device is not on the plan (Hallway & "target")'; out.unplacedNameIsSafeText = !missingParentBubble?.querySelector('router,img,script') && !missingParentBubble?.textContent.includes('Temperature sensor'); activeOverlay.devices = activeOverlay.devices.map((device) => ( device.id === 'd_lamp' ? { ...device, hidden: true } : device )); await activeOverlay.updateComplete; const router = root().querySelector('.dev[data-id="d_light1"]'); router.dispatchEvent(mouse('pointerover')); await wait(() => activeOverlay.shadowRoot.querySelector('[data-kind="unplaced-coordinator"]'), 'unplaced coordinator bubble'); out.unplacedCoordinatorBubble = activeOverlay.shadowRoot .querySelector('[data-kind="unplaced-coordinator"]')?.textContent.trim() === 'coordinator is not on the plan'; const parentCore = activeOverlay.shadowRoot.querySelector('line.parent-route'); const parentArrow = activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-parent-arrow"]'); out.parentRouteHasUnknownOutline = !!parentCore && !!parentArrow && getComputedStyle(parentCore).stroke === 'rgb(145, 155, 165)' && getComputedStyle(parentArrow).fill === 'rgb(145, 155, 165)' && getComputedStyle(parentArrow).stroke === 'rgb(0, 0, 0)' && Math.abs(Number.parseFloat(getComputedStyle(parentArrow).strokeWidth) - 2 * card._zoom) < 0.001 && [...activeOverlay.shadowRoot.querySelectorAll('line')].some((line) => ( getComputedStyle(line).stroke === 'rgb(0, 0, 0)' && Math.abs(Number.parseFloat(getComputedStyle(line).strokeWidth) - (2 + 2 * card._zoom)) < 0.001 )) && [...activeOverlay.shadowRoot.querySelectorAll('line,polygon')] .every((node) => getComputedStyle(node).strokeDasharray === 'none'); router.dispatchEvent(mouse('pointerout', root().querySelector('.stage'))); await activeOverlay.updateComplete; out.leaveClears = !activeOverlay.shadowRoot.querySelector('line') && !root().querySelector('[data-hp-zigbee-topology-endpoint]'); router.dispatchEvent(mouse('pointerover')); await wait(() => activeOverlay.shadowRoot.querySelector('line'), 'route restored'); activeOverlay.dispatchEvent(new PointerEvent('pointerdown', { pointerType: 'touch', bubbles: true, composed: true, pointerId: 7, })); await wait(() => !activeOverlay.shadowRoot.querySelector('line'), 'touch clears'); out.touchClears = !activeOverlay.shadowRoot.querySelector('line') && !root().querySelector('[data-hp-zigbee-topology-endpoint]'); card.setAttribute('data-pointer-hover', ''); router.dispatchEvent(mouse('pointerover')); await wait(() => activeOverlay.shadowRoot.querySelector('line'), 'route restored after touch'); activeOverlay.dispatchEvent(new PointerEvent('pointerdown', { pointerType: 'pen', bubbles: true, composed: true, pointerId: 8, })); await wait(() => !activeOverlay.shadowRoot.querySelector('line'), 'pen clears'); out.penClears = !root().querySelector('[data-hp-zigbee-topology-endpoint]'); card.setAttribute('data-pointer-hover', ''); router.dispatchEvent(mouse('pointerover')); await wait(() => activeOverlay.shadowRoot.querySelector('line'), 'route restored before hover gate loss'); card.removeAttribute('data-pointer-hover'); await wait(() => !activeOverlay.shadowRoot.querySelector('line'), 'hover gate clears'); out.hoverGateClears = !root().querySelector('[data-hp-zigbee-topology-endpoint]'); card.setAttribute('data-pointer-hover', ''); card._setMode('plan'); await card.updateComplete; out.editorHasNoOverlay = !root().querySelector('hp-zigbee-topology-overlay') && !root().querySelector('[data-hp-zigbee-topology-endpoint]'); card._setMode('view'); await card.updateComplete; card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, zigbee_topology: { ...card._serverCfg.settings.zigbee_topology, enabled: false }, } }; card.requestUpdate(); await card.updateComplete; out.settingOffClears = !root().querySelector('hp-zigbee-topology-overlay') && !root().querySelector('[data-hp-zigbee-topology-endpoint]'); card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, zigbee_topology: { ...card._serverCfg.settings.zigbee_topology, enabled: true }, } }; card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: false } }; await card.updateComplete; out.nonAdminHasNoOverlay = !root().querySelector('hp-zigbee-topology-overlay') && !root().querySelector('[data-hp-zigbee-topology-endpoint]') && zhaCalls === 1; return out; }); // Exercise the actual rendered line/arrow pair, not the palette helper in isolation. // Remote and unplaced targets used to be permanently grey even with known LQI. for (const theme of ['light', 'dark']) { await page.emulateMedia({ colorScheme: theme }); const visual = await page.evaluate(async ({ theme }) => { const card = window.__card; const root = () => card.shadowRoot || card.renderRoot; const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const wait = async (predicate, label = 'render') => { const started = performance.now(); while (!predicate()) { if (performance.now() - started > 5000) throw new Error(`topology visual contract timeout: ${theme} ${label}; ` + root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot?.textContent?.slice(-600)); await new Promise((resolve) => setTimeout(resolve, 20)); } }; const hover = (marker) => marker.dispatchEvent(new PointerEvent('pointerover', { pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200, })); document.documentElement.style.colorScheme = theme; card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: true }, themes: { ...(card.hass.themes || {}), darkMode: theme === 'dark' } }; card.setAttribute('data-pointer-hover', ''); await window.__hpTest.setMode('view'); card.requestUpdate(); await card.updateComplete; await wait(() => root().querySelector('hp-zigbee-topology-overlay')); // The preceding editor/View probe starts a real camera transition. Let its // saved View camera settle before measuring the explicit zoom matrix. await wait(() => !card._modeTransitionBusy, 'View camera settled'); await frame(); const expectedColors = [ [0, 'rgb(255, 0, 0)'], [64, 'rgb(255, 128, 0)'], [128, 'rgb(255, 255, 0)'], [192, 'rgb(126, 255, 0)'], [255, 'rgb(0, 255, 0)'], [null, 'rgb(145, 155, 165)'], ]; const out = {}; const diagnostics = []; let revision = window.__zigbeeSmokeBaseline.revision + 100; const snapshot = (lqi) => ({ ...window.__zigbeeSmokeBaseline, revision: revision++, topologies: window.__zigbeeSmokeBaseline.topologies.map((topology) => ({ ...topology, obtainedAt: Date.now(), uplinkEvidence: topology.uplinkEvidence.map((evidence) => ({ ...evidence, lqi: lqi ?? undefined })), links: topology.links.map((link) => ({ ...link, aToB: link.aToB ? { ...link.aToB, lqi: lqi ?? undefined } : undefined, bToA: link.bToA ? { ...link.bToA, lqi: lqi ?? undefined } : undefined, })), })), }); for (const [kind, space, markerId] of [ ['local', 'f1', 'd_light1'], ['remote', 'garden', 'd_mower'], ['unplaced', 'f1', 'd_temp'], ]) { card._commitSpace(space, true); card.requestUpdate(); await card.updateComplete; for (const [zoomName, zoom] of [['min', 1 / 3], ['default', 1], ['max', 8]]) { card._applyView(zoom, 0.32, 0.36); card.requestUpdate(); await card.updateComplete; await frame(); const overlay = root().querySelector('hp-zigbee-topology-overlay'); await overlay.updateComplete; const marker = root().querySelector(`.dev[data-id="${markerId}"]`); hover(marker); for (const [lqi, expected] of expectedColors) { overlay._acceptRuntime(snapshot(lqi)); await overlay.updateComplete; const cores = [...overlay.shadowRoot.querySelectorAll('.link-core,.parent-route')]; const arrows = [...overlay.shadowRoot.querySelectorAll('.route-arrow')]; const casings = [...overlay.shadowRoot.querySelectorAll('.link-casing')]; const key = `${kind}_${zoomName}_${lqi ?? 'unknown'}`; out[`${key}_requestedZoom`] = Math.abs(card._zoom - zoom) < 0.000001 && Math.abs(overlay.zoom - zoom) < 0.000001; out[`${key}_lineAndArrowColor`] = cores.length > 0 && arrows.length === cores.length && cores.every((line) => getComputedStyle(line).stroke === expected) && arrows.every((arrow) => getComputedStyle(arrow).fill === expected); out[`${key}_solid`] = [...cores, ...arrows, ...casings] .every((node) => getComputedStyle(node).strokeDasharray === 'none'); out[`${key}_outline`] = lqi === null ? casings.length === cores.length && casings.every((line) => getComputedStyle(line).stroke === 'rgb(0, 0, 0)' && Math.abs(Number.parseFloat(getComputedStyle(line).strokeWidth) - (2 + 2 * zoom)) < 0.001 && getComputedStyle(line).vectorEffect === 'non-scaling-stroke') && cores.every((line) => Number.parseFloat(getComputedStyle(line).strokeWidth) === 2 && getComputedStyle(line).vectorEffect === 'non-scaling-stroke') && arrows.every((arrow) => getComputedStyle(arrow).stroke === 'rgb(0, 0, 0)' && Math.abs(Number.parseFloat(getComputedStyle(arrow).strokeWidth) - 2 * zoom) < 0.001 && getComputedStyle(arrow).vectorEffect === 'non-scaling-stroke' && getComputedStyle(arrow).paintOrder.startsWith('stroke')) : casings.length === 0 && arrows.every((arrow) => getComputedStyle(arrow).stroke === 'none'); if (!out[`${key}_outline`]) diagnostics.push({ key, zoom, cardZoom: card._zoom, overlayZoom: overlay.zoom, cores: cores.map((line) => ({ width: getComputedStyle(line).strokeWidth, effect: getComputedStyle(line).vectorEffect })), casings: casings.map((line) => ({ width: getComputedStyle(line).strokeWidth, color: getComputedStyle(line).stroke, effect: getComputedStyle(line).vectorEffect })), arrows: arrows.map((arrow) => ({ width: getComputedStyle(arrow).strokeWidth, color: getComputedStyle(arrow).stroke, effect: getComputedStyle(arrow).vectorEffect, order: getComputedStyle(arrow).paintOrder })), }); } } } card._commitSpace('f1', true); card.requestUpdate(); await card.updateComplete; await frame(); const overlay = root().querySelector('hp-zigbee-topology-overlay'); await overlay.updateComplete; const knownSource = root().querySelector('.dev[data-id="d_light1"]'); hover(knownSource); const stale = snapshot(null); stale.topologies = stale.topologies.map((topology) => ({ ...topology, obtainedAt: Date.now() - 61 * 60 * 1000, warnings: [...topology.warnings, { code: 'provider_scan_failure' }], })); overlay._acceptRuntime(stale); await overlay.updateComplete; const staleStatus = overlay.shadowRoot.querySelector('.route-status'); out.stalePartialRetainsKnownRoute = staleStatus?.getAttribute('data-outgoing') === 'known' && /stale data/i.test(staleStatus.textContent) && /incomplete data/i.test(staleStatus.textContent) && !!overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]') && [...overlay.shadowRoot.querySelectorAll('line,polygon')] .every((node) => getComputedStyle(node).strokeDasharray === 'none'); const failedRefresh = snapshot(null); failedRefresh.states = { ...failedRefresh.states, zha: { phase: 'error', error: 'provider', obtainedAt: failedRefresh.topologies[0].obtainedAt, stale: true, } }; overlay._acceptRuntime(failedRefresh); await overlay.updateComplete; out.failedRefreshMarksRetainedRouteStale = /stale data/i.test( overlay.shadowRoot.querySelector('.route-status')?.textContent || '', ) && !!overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]'); const expiring = snapshot(null); expiring.topologies = expiring.topologies.map((topology) => ({ ...topology, obtainedAt: Date.now() - 60 * 60 * 1000 + 300, })); const fetchCount = window.__zigbeeSmokeFetchCount(); overlay._acceptRuntime(expiring); await overlay.updateComplete; const initiallyFresh = !/stale data/i.test(overlay.shadowRoot.querySelector('.route-status')?.textContent || ''); await wait(() => /stale data/i.test(overlay.shadowRoot.querySelector('.route-status')?.textContent || '')); out.ageTimerUpdatesWithoutRefetch = initiallyFresh && window.__zigbeeSmokeFetchCount() === fetchCount && !!overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]'); overlay._acceptRuntime(window.__zigbeeSmokeBaseline); await overlay.updateComplete; if (theme === 'dark') { const originalHass = overlay.hass; overlay.hass = { ...overlay.hass, language: 'ru', locale: { ...overlay.hass.locale, language: 'ru' } }; await overlay.updateComplete; hover(root().querySelector('.dev[data-id="d_temp"]')); await wait(() => overlay.shadowRoot.querySelector('[data-kind="unplaced-device"]')?.textContent .includes('Устройства нет на плане'), 'localized missing target'); const named = overlay.shadowRoot.querySelector('[data-kind="unplaced-device"]'); out.localizedTargetName = named.textContent.trim() === 'Устройства нет на плане (Hallway & "target")' && !named.querySelector('router,img,script'); hover(root().querySelector('.dev[data-id="d_kettle"]')); await wait(() => overlay.shadowRoot.querySelector('.route-status')?.textContent.includes('Нет данных о маршруте')); out.localizedUnknownRoute = !overlay.shadowRoot.querySelector('line,polygon'); overlay.hass = originalHass; await overlay.updateComplete; } return { checks: out, diagnostics }; }, { theme }); if (visual.diagnostics.length) console.log('Topology visual diagnostics:', JSON.stringify(visual.diagnostics)); for (const [name, value] of Object.entries(visual.checks)) result[`visual_${theme}_${name}`] = value; } for (const [aspect, viewport] of Object.entries({ wide: { width: 1100, height: 500 }, tall: { width: 480, height: 900 }, })) { await page.setViewportSize(viewport); for (const [zoomName, zoom] of Object.entries({ min: 1 / 3, default: 1, max: 8 })) { const geometry = await page.evaluate(async ({ zoom, hostWidth }) => { const card = window.__card; const root = () => card.shadowRoot || card.renderRoot; const wait = (predicate, timeout = 5000) => new Promise((resolve, reject) => { const started = performance.now(); const tick = () => { if (predicate()) return resolve(); if (performance.now() - started > timeout) return reject(new Error('topology geometry timeout')); setTimeout(tick, 20); }; tick(); }); document.querySelector('#host').style.width = `${hostWidth}px`; card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: true } }; card._setMode('view'); card._commitSpace('f1', true); card._zoom = 1; card._view = null; card.requestUpdate(); await card.updateComplete; card._applyView(zoom, 0.32, 0.36); card.requestUpdate(); await card.updateComplete; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const source = root().querySelector('.dev[data-id="d_light1"]'); source.dispatchEvent(new PointerEvent('pointerover', { pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200, })); await wait(() => root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot ?.querySelector('[data-hp="zigbee-topology-arrow"]')); const overlay = root().querySelector('hp-zigbee-topology-overlay'); await overlay.updateComplete; const svg = overlay.shadowRoot.querySelector('svg'); const arrow = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]'); const line = overlay.shadowRoot.querySelector( 'line[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]', ); const points = arrow.getAttribute('points').trim().split(/\s+/).map((pair) => { const [x, y] = pair.split(',').map(Number); return new DOMPoint(x, y).matrixTransform(svg.getScreenCTM()); }); const tip = points[0]; const base = new DOMPoint((points[1].x + points[2].x) / 2, (points[1].y + points[2].y) / 2); const length = Math.hypot(tip.x - base.x, tip.y - base.y); const halfWidth = Math.hypot(points[1].x - points[2].x, points[1].y - points[2].y) / 2; const lineVector = { x: Number(line.getAttribute('x2')) - Number(line.getAttribute('x1')), y: Number(line.getAttribute('y2')) - Number(line.getAttribute('y1')), }; const arrowVector = { x: tip.x - base.x, y: tip.y - base.y }; const alignment = (lineVector.x * arrowVector.x + lineVector.y * arrowVector.y) / (Math.hypot(lineVector.x, lineVector.y) * Math.hypot(arrowVector.x, arrowVector.y)); const devlayer = root().querySelector('.devlayer'); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); devlayer.style.transformOrigin = '0 0'; devlayer.style.transform = 'translate(7%, 5%) scale(.88)'; const liveOverlay = root().querySelector('hp-zigbee-topology-overlay'); const liveSvg = liveOverlay.shadowRoot.querySelector('svg'); const liveLine = liveOverlay.shadowRoot.querySelector( 'line[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]', ); const liveSource = root().querySelector('.dev[data-id="d_light1"]'); const sourceRect = liveSource.getBoundingClientRect(); const originScreen = new DOMPoint( Number(liveLine.getAttribute('x1')), Number(liveLine.getAttribute('y1')), ).matrixTransform(liveSvg.getScreenCTM()); const liveAligned = Math.hypot( originScreen.x - (sourceRect.left + sourceRect.width / 2), originScreen.y - (sourceRect.top + sourceRect.height / 2), ) < 1.5; const parentProjected = !!devlayer.style.transform; const overlaySharesParent = liveOverlay.parentElement === devlayer; const overlayHasNoProjection = !liveOverlay.hasAttribute('data-hp-live-layer') && !liveOverlay.style.transform; devlayer.style.removeProperty('transform'); devlayer.style.removeProperty('transform-origin'); card.requestUpdate(); await card.updateComplete; await new Promise((resolve) => requestAnimationFrame(resolve)); const settledLayer = root().querySelector('.devlayer'); const settledOverlay = root().querySelector('hp-zigbee-topology-overlay'); await settledOverlay.updateComplete; const settledSvg = settledOverlay.shadowRoot.querySelector('svg'); const settledLine = settledOverlay.shadowRoot.querySelector( 'line[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]', ); const settledSource = root().querySelector('.dev[data-id="d_light1"]'); const settledRect = settledSource.getBoundingClientRect(); const settledOrigin = new DOMPoint( Number(settledLine.getAttribute('x1')), Number(settledLine.getAttribute('y1')), ).matrixTransform(settledSvg.getScreenCTM()); const settledAligned = Math.hypot( settledOrigin.x - (settledRect.left + settledRect.width / 2), settledOrigin.y - (settledRect.top + settledRect.height / 2), ) < 1.5; return { arrowGeometry: length > 8.5 && length < 9.5 && halfWidth > 4 && halfWidth < 5 && alignment > 0.999, pointerTransparent: getComputedStyle(overlay).pointerEvents === 'none', parentProjected, overlaySharesParent, overlayHasNoProjection, liveAligned, settledTransformCleared: !settledLayer.style.transform, settledAligned, }; }, { zoom, hostWidth: Math.max(320, viewport.width - 40) }); for (const [name, value] of Object.entries(geometry)) { result[`geometry_${aspect}_${zoomName}_${name}`] = value; } } } for (const theme of ['light', 'dark']) { await page.emulateMedia({ colorScheme: theme }); await page.setViewportSize({ width: 1100, height: 620 }); const rasterProbe = await page.evaluate(async ({ theme }) => { const card = window.__card; const root = card.shadowRoot || card.renderRoot; const wait = (predicate, timeout = 5000) => new Promise((resolve, reject) => { const started = performance.now(); const tick = () => { if (predicate()) return resolve(); if (performance.now() - started > timeout) return reject(new Error('topology raster timeout')); setTimeout(tick, 20); }; tick(); }); document.querySelector('#host').style.width = '1060px'; card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: true }, themes: { ...(card.hass.themes || {}), darkMode: theme === 'dark' } }; document.documentElement.style.colorScheme = theme; card._setMode('view'); card._commitSpace('f1', true); card._zoom = 1; card._view = null; card.requestUpdate(); await card.updateComplete; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const source = root.querySelector('.dev[data-id="d_light1"]'); const neighbor = root.querySelector('.dev[data-id="d_lamp"]'); const known = root.querySelector('.dev[data-id="d_tv"]'); const unrelated = root.querySelector('.dev[data-id="d_temp"]'); let label = root.querySelector('.roomlabel'); const event = (type, relatedTarget = null) => new PointerEvent(type, { pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200, relatedTarget, }); const existing = root.querySelector('hp-zigbee-topology-overlay'); source.dispatchEvent(event('pointerout', root.querySelector('.stage'))); await existing?.updateComplete; source.dispatchEvent(event('pointerover')); await wait(() => root.querySelector('hp-zigbee-topology-overlay')?.shadowRoot ?.querySelector('[data-hp="zigbee-topology-line-casing"]')); const overlay = root.querySelector('hp-zigbee-topology-overlay'); await card.updateComplete; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const renderedLabel = root.querySelector('.roomlabel'); root.querySelectorAll('[data-hp="zigbee-topology-room-label-raster-control"]').forEach((node) => node.remove()); label = renderedLabel?.cloneNode(true) || document.createElement('div'); label.className = 'roomlabel'; label.dataset.hp = 'zigbee-topology-room-label-raster-control'; label.removeAttribute('data-id'); source.style.left = '18%'; source.style.top = '42%'; neighbor.style.left = '82%'; neighbor.style.top = '42%'; known.style.left = '82%'; known.style.top = '70%'; unrelated.style.left = '48%'; unrelated.style.top = '42%'; if (label) { label.style.left = '66%'; label.style.top = '42%'; label.style.width = '28px'; label.style.height = '28px'; label.style.background = '#ff00ff'; label.style.fontSize = '0'; } overlay.parentElement.insertBefore(label, overlay); overlay.viewKey = { raster: true }; await overlay.updateComplete; const svg = overlay.shadowRoot.querySelector('svg'); const line = overlay.shadowRoot.querySelector( '[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]', ); const arrow = overlay.shadowRoot.querySelector( '[data-hp="zigbee-topology-arrow"][data-direction="toward-neighbor"]', ); const matrix = svg.getScreenCTM(); const start = new DOMPoint(Number(line.getAttribute('x1')), Number(line.getAttribute('y1'))) .matrixTransform(matrix); const end = new DOMPoint(Number(line.getAttribute('x2')), Number(line.getAttribute('y2'))) .matrixTransform(matrix); const centre = (node) => { const rect = node.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; }; const labelCentre = label ? centre(label) : null; const arrowPoints = arrow.getAttribute('points').trim().split(/\s+/).map((pair) => { const [x, y] = pair.split(',').map(Number); const point = new DOMPoint(x, y).matrixTransform(matrix); return { x: point.x, y: point.y }; }); return { dpr: devicePixelRatio, line: { start: { x: start.x, y: start.y }, end: { x: end.x, y: end.y } }, source: centre(source), neighbor: centre(neighbor), unrelated: centre(unrelated), label: labelCentre ? { x: labelCentre.x, y: start.y } : null, labelContainsLine: !labelCentre || Math.abs(labelCentre.y - start.y) <= 14, arrowPoints, }; }, { theme }); const activeRaster = await page.screenshot({ animations: 'disabled', path: fileURLToPath(new URL(`known-route-missing-lqi-${theme}.png`, visualArtifacts)) }); await page.evaluate(() => { const card = window.__card; (card.shadowRoot || card.renderRoot).querySelector('hp-zigbee-topology-overlay').style.visibility = 'hidden'; }); const baselineRaster = await page.screenshot({ animations: 'disabled' }); await page.evaluate(() => { const card = window.__card; (card.shadowRoot || card.renderRoot).querySelector('hp-zigbee-topology-overlay').style.visibility = ''; }); const rasterEvidence = await page.evaluate(async ({ active, baseline, probe }) => { const decode = async (base64) => { const response = await fetch(`data:image/png;base64,${base64}`); const bitmap = await createImageBitmap(await response.blob()); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const context = canvas.getContext('2d', { willReadFrequently: true }); context.drawImage(bitmap, 0, 0); return { width: bitmap.width, height: bitmap.height, data: context.getImageData(0, 0, bitmap.width, bitmap.height).data }; }; const a = await decode(active); const b = await decode(baseline); if (a.width !== b.width || a.height !== b.height) return { unrelatedAbove: false, labelAbove: false, endpointsAbove: false, casingInk: false, continuousCore: false, arrowBlackOutline: false, }; const changed = (x, y, threshold = 24) => { const px = Math.max(0, Math.min(a.width - 1, Math.round(x * probe.dpr))); const py = Math.max(0, Math.min(a.height - 1, Math.round(y * probe.dpr))); const i = (py * a.width + px) * 4; return Math.abs(a.data[i] - b.data[i]) + Math.abs(a.data[i + 1] - b.data[i + 1]) + Math.abs(a.data[i + 2] - b.data[i + 2]) > threshold; }; const changedIn = (point, rx, ry) => { if (!point) return 0; let count = 0; for (let y = -ry; y <= ry; y++) for (let x = -rx; x <= rx; x++) { if (changed(point.x + x, point.y + y)) count++; } return count; }; const unrelatedInk = changedIn(probe.unrelated, 5, 4); const labelInk = changedIn(probe.label, 8, 4); const endpointInk = changedIn(probe.source, 3, 3) + changedIn(probe.neighbor, 3, 3); const dx = probe.line.end.x - probe.line.start.x; const dy = probe.line.end.y - probe.line.start.y; const length = Math.hypot(dx, dy); const ux = dx / length; const uy = dy / length; const nx = -uy; const ny = ux; let coreAndCasing = 0; let paintedCore = 0; let samples = 0; for (let distance = Math.max(35, length * 0.18); distance < Math.min(length - 35, length * 0.38); distance += 1) { const x = probe.line.start.x + ux * distance; const y = probe.line.start.y + uy * distance; const core = changed(x, y); const edge = changed(x + nx * 1.75, y + ny * 1.75) || changed(x - nx * 1.75, y - ny * 1.75); samples++; if (core) paintedCore++; if (core && edge) coreAndCasing++; } // Sample just outside both sloping polygon sides. A fill-only triangle cannot // supply dark pixels beyond its geometry; the black 1px outer outline can. const darkPixel = (x, y) => { const px = Math.max(0, Math.min(a.width - 1, Math.floor(x * probe.dpr))); const py = Math.max(0, Math.min(a.height - 1, Math.floor(y * probe.dpr))); const i = (py * a.width + px) * 4; return Math.max(a.data[i], a.data[i + 1], a.data[i + 2]) < 85; }; const arrowCentre = probe.arrowPoints.reduce((sum, point) => ({ x: sum.x + point.x / 3, y: sum.y + point.y / 3, }), { x: 0, y: 0 }); let blackSides = 0; for (const side of [1, 2]) { const tip = probe.arrowPoints[0]; const corner = probe.arrowPoints[side]; const dx = corner.x - tip.x; const dy = corner.y - tip.y; const sideLength = Math.hypot(dx, dy); const mid = { x: (tip.x + corner.x) / 2, y: (tip.y + corner.y) / 2 }; let nx = -dy / sideLength; let ny = dx / sideLength; if ((arrowCentre.x - mid.x) * nx + (arrowCentre.y - mid.y) * ny > 0) { nx = -nx; ny = -ny; } let ink = 0; for (const t of [0.25, 0.4, 0.55, 0.7]) { const x = tip.x + dx * t + nx * 0.5; const y = tip.y + dy * t + ny * 0.5; if (darkPixel(x, y) && changed(x, y)) ink++; } if (ink >= 2) blackSides++; } return { unrelatedAbove: unrelatedInk >= 3, labelAbove: labelInk >= 2, endpointsAbove: endpointInk <= 2, casingInk: coreAndCasing >= 6, continuousCore: samples >= 12 && paintedCore / samples >= 0.98, arrowBlackOutline: blackSides === 2, }; }, { active: activeRaster.toString('base64'), baseline: baselineRaster.toString('base64'), probe: rasterProbe, }); result[`raster_${theme}_labelContainsLine`] = rasterProbe.labelContainsLine; for (const [name, value] of Object.entries(rasterEvidence)) { result[`raster_${theme}_${name}`] = value; } } // Owner contract: the outline grows with the plan, including the transient // camera transform on the common device layer. Use actual screen pixels here: // computed SVG stroke widths alone cannot distinguish ancestor CSS scaling. await page.evaluate(async () => { const card = window.__card; const root = card.shadowRoot || card.renderRoot; const layer = root.querySelector('.devlayer'); const source = root.querySelector('.dev[data-id="d_light1"]'); const target = root.querySelector('.dev[data-id="d_lamp"]'); window.__zigbeeOutlineProbeStyles = [layer, source, target].map((node) => [node, node.getAttribute('style')]); // The camera owns the layer's inline transform and can clear it on a queued // commit. Keep this synthetic live-transform fixture stable across captures. layer.setAttribute('data-hp-zigbee-outline-probe', ''); const style = document.createElement('style'); root.append(style); window.__zigbeeOutlineProbeStyle = style; source.style.left = '10%'; source.style.top = '30%'; target.style.left = '42%'; target.style.top = '30%'; const overlay = root.querySelector('hp-zigbee-topology-overlay'); overlay.viewKey = { outlineGrowth: true }; source.dispatchEvent(new PointerEvent('pointerover', { pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200, })); await overlay.updateComplete; }); const outlinePixelMass = []; for (const scale of [0.5, 1, 2]) { const probe = await page.evaluate(async ({ scale }) => { const card = window.__card; const root = card.shadowRoot || card.renderRoot; window.__zigbeeOutlineProbeStyle.textContent = `[data-hp-zigbee-outline-probe] { transform: scale(${scale}) !important; transform-origin: 0 0 !important; background: #fff !important; } /* #802: isolate route ink. At scale=.5 the ordinary pointer tooltip can cover the target arrow; its dark background is not outline coverage. Real tooltip/badge coexistence is witnessed in smoke_zigbee_tooltip_layout. */ [data-hp-live-tip] { display: none !important; }`; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const overlay = root.querySelector('hp-zigbee-topology-overlay'); const svg = overlay.shadowRoot.querySelector('svg'); const line = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]'); const arrow = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"][data-direction="toward-neighbor"]'); const point = new DOMPoint( Number(line.getAttribute('x1')) * 0.55 + Number(line.getAttribute('x2')) * 0.45, Number(line.getAttribute('y1')), ).matrixTransform(svg.getScreenCTM()); const rect = arrow.getBoundingClientRect(); return { point: { x: point.x, y: point.y }, scale, dpr: devicePixelRatio, arrow: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } }; }, { scale }); const pixels = await page.screenshot({ animations: 'disabled', path: fileURLToPath(new URL(`outline-live-scale-${scale}.png`, visualArtifacts)) }); outlinePixelMass.push(await page.evaluate(async ({ image, probe }) => { const bitmap = await createImageBitmap(await (await fetch(`data:image/png;base64,${image}`)).blob()); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0); const pixels = ctx.getImageData(0, 0, bitmap.width, bitmap.height).data; // White fixture backdrop and grey core contribute zero. Black casing // contributes one per pixel; antialiased coverage contributes a fraction. const black = (x, y) => { const i = (y * bitmap.width + x) * 4; return Math.max(0, (165 - Math.max(pixels[i], pixels[i + 1], pixels[i + 2])) / 165); }; const x = Math.round(probe.point.x * probe.dpr); const y = Math.round(probe.point.y * probe.dpr); let line = 0; const radius = Math.ceil(4 * probe.scale * probe.dpr); for (let dx = -3; dx <= 3; dx++) for (let dy = -radius; dy <= radius; dy++) { line += black(x + dx, y + dy) / (7 * probe.dpr); } const pad = 2 * probe.scale; const bounds = { left: Math.floor((probe.arrow.x - pad) * probe.dpr), top: Math.floor((probe.arrow.y - pad) * probe.dpr), right: Math.ceil((probe.arrow.x + probe.arrow.width + pad) * probe.dpr), bottom: Math.ceil((probe.arrow.y + probe.arrow.height + pad) * probe.dpr), }; let arrow = 0; const inFrame = bounds.left >= 0 && bounds.top >= 0 && bounds.right < bitmap.width && bounds.bottom < bitmap.height; if (inFrame) for (let py = bounds.top; py <= bounds.bottom; py++) for (let px = bounds.left; px <= bounds.right; px++) { arrow += black(px, py) / (probe.dpr * probe.dpr); } return { scale: probe.scale, line, arrow, inFrame }; }, { image: pixels.toString('base64'), probe })); } result.livePlanScaleGrowsLineOutline = outlinePixelMass.every((sample) => sample.inFrame && sample.line > 0) && outlinePixelMass[1].line > outlinePixelMass[0].line * 1.4 && outlinePixelMass[2].line > outlinePixelMass[1].line * 1.4; result.livePlanScaleGrowsArrowOutline = outlinePixelMass.every((sample) => sample.arrow > 0) && outlinePixelMass[1].arrow > outlinePixelMass[0].arrow * 2.5 && outlinePixelMass[2].arrow > outlinePixelMass[1].arrow * 2.5; console.log('Topology live outline pixel mass:', JSON.stringify(outlinePixelMass)); await page.evaluate(async () => { window.__zigbeeOutlineProbeStyle.remove(); for (const [node, style] of window.__zigbeeOutlineProbeStyles) { node.removeAttribute('data-hp-zigbee-outline-probe'); if (style === null) node.removeAttribute('style'); else node.setAttribute('style', style); } const card = window.__card; const overlay = (card.shadowRoot || card.renderRoot).querySelector('hp-zigbee-topology-overlay'); overlay.viewKey = { outlineGrowth: false }; await overlay.updateComplete; }); const realHoverPoint = await page.evaluate(() => { const card = window.__card; const root = card.shadowRoot || card.renderRoot; const source = root.querySelector('.dev[data-id="d_light1"]'); const rect = source.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; }); await page.mouse.move(realHoverPoint.x, realHoverPoint.y); result.realPointerEndpointWins = await page.evaluate(async () => { const card = window.__card; const root = card.shadowRoot || card.renderRoot; const source = root.querySelector('.dev[data-id="d_light1"]'); const overlay = root.querySelector('hp-zigbee-topology-overlay'); const started = performance.now(); while ((!source.matches(':hover') || !source.hasAttribute('data-hp-zigbee-topology-endpoint') || !overlay?.shadowRoot?.querySelector('[data-hp="zigbee-topology-line"]')) && performance.now() - started < 5000) { await new Promise((resolve) => setTimeout(resolve, 20)); } return source.matches(':hover') && source.hasAttribute('data-hp-zigbee-topology-endpoint') && Number.parseInt(getComputedStyle(source).zIndex, 10) > Number.parseInt(getComputedStyle(overlay.shadowRoot.querySelector('svg')).zIndex, 10); }); await page.emulateMedia({ forcedColors: 'active' }); result.forcedColorsPreserved = await page.evaluate(() => { const card = window.__card; const root = card.shadowRoot || card.renderRoot; const overlay = root.querySelector('hp-zigbee-topology-overlay'); const casing = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-line-casing"]'); const core = overlay.shadowRoot.querySelector( '[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]', ); return !!casing && !!core && getComputedStyle(casing).stroke === getComputedStyle(core).stroke && getComputedStyle(casing).stroke !== 'rgb(0, 0, 0)'; }); await page.emulateMedia({ forcedColors: 'none' }); result.disconnectClears = await page.evaluate(async () => { const card = window.__card; const root = card.shadowRoot || card.renderRoot; const source = root.querySelector('.dev[data-id="d_light1"]'); const overlay = root.querySelector('hp-zigbee-topology-overlay'); source.dispatchEvent(new PointerEvent('pointerover', { pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200, })); await overlay.updateComplete; const owned = [...root.querySelectorAll('[data-hp-zigbee-topology-endpoint]')]; card.remove(); await Promise.resolve(); return owned.length >= 2 && owned.every( (marker) => !marker.hasAttribute('data-hp-zigbee-topology-endpoint'), ); }); checkAll(result); await finish(browser, result);