Files
houseplan-card/demo/smoke_readonly_cold_start.mjs
T
2026-08-13 23:28:19 +00:00

188 lines
8.3 KiB
JavaScript

// #131: a read-only HA session may read the House Plan snapshot while HA
// rejects event subscriptions. The initial frame must still select one exact
// space and render every raw-space layer before any user click.
import { launch, checkAll, finish } from './serve.mjs';
import { makeVisualMatrixFixture } from './fixtures/visual-matrix.mjs';
const fixture = makeVisualMatrixFixture();
const { page, browser } = await launch({ width: 820, height: 760 });
const result = await page.evaluate(async (rawFixture) => {
const out = {};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const HP = customElements.get('houseplan-card');
const host = document.getElementById('host');
window.__card.remove();
HP._warmBootReset?.();
for (const key of Object.keys(localStorage)) {
if (key.startsWith('houseplan_card_')) localStorage.removeItem(key);
}
history.replaceState(null, '', '/demo.html');
const fixture = structuredClone(rawFixture);
const lighting = fixture.config.spaces.find((space) => space.id === 'golden-lighting');
const geometry = fixture.config.spaces.find((space) => space.id === 'golden-geometry');
lighting.id = 'home';
lighting.title = 'Home';
lighting.settings = {
...lighting.settings,
fill_mode: 'custom',
custom_fill: { c: '#cdbb96', a: 0.42 },
glow_enabled: true,
show_borders: true,
};
lighting.decor = [{
id: 'readonly-furniture', kind: 'rect', x: 0.16, y: 0.18, w: 0.12, h: 0.08,
color: '#8d6e63', opacity: 1, width_cm: 2, fill: true,
fill_color: '#bcaaa4', fill_opacity: 1,
}];
geometry.id = 'upstairs';
geometry.title = 'Upstairs';
fixture.config.spaces = [lighting, geometry];
fixture.config.markers.push({
id: 'golden-light-one', binding: 'device:golden-light-one', display: 'value',
});
fixture.layout = Object.fromEntries(Object.entries(fixture.layout).map(([id, pos]) => [
id,
{ ...pos, s: pos.s === 'golden-lighting' ? 'home' : pos.s === 'golden-geometry' ? 'upstairs' : pos.s },
]));
const base = window.__mkHass();
const makeHass = (subscriptionMode = 'partial') => {
const calls = { configGets: 0, events: {}, unsubscribed: [] };
const connection = {
subscribeEvents: async (callback, event) => {
calls.events[event] = (calls.events[event] || 0) + 1;
if (String(event).startsWith('houseplan_')
&& (subscriptionMode === 'reject-all'
|| event === 'houseplan_config_updated' || event === 'houseplan_trail_updated')) {
throw new Error('unauthorized');
}
return () => { calls.unsubscribed.push(event); };
},
subscribeMessage: async () => () => {},
};
const hass = {
...base,
user: { id: 'readonly', name: 'Readonly', is_admin: false },
devices: fixture.devices,
entities: fixture.entities,
areas: fixture.areas,
states: fixture.states,
connection,
callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
calls.configGets += 1;
return { config: structuredClone(fixture.config), rev: 131, can_write: false };
}
if (message.type === 'houseplan/layout/get') {
return { layout: structuredClone(fixture.layout), rev: 131 };
}
if (message.type === 'houseplan/trail/get') return { trails: {} };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
if (message.type === 'config_entries/get') {
return [{ entry_id: 'golden_entry', domain: 'houseplan_golden', title: 'Golden' }];
}
if (message.type === 'manifest/list') {
return [{ domain: 'houseplan_golden', name: 'Golden' }];
}
return { ok: true };
},
};
return { hass, calls };
};
const layerSnapshot = (card) => {
const root = card.shadowRoot || card.renderRoot;
return {
exactSpace: card._curSpaceCfg?.id || null,
activeSpace: root.querySelector('[data-hp="space-tab"].active')?.getAttribute('data-id') || null,
rooms: root.querySelectorAll('[data-hp="room"]').length,
decor: root.querySelectorAll('[data-hp="decor"]').length,
walls: root.querySelectorAll('.wallbodies .wallbody-fill').length,
glow: root.querySelectorAll('.glow-spot').length,
devices: root.querySelectorAll('[data-hp="device"]').length,
values: root.querySelectorAll('[data-hp="device"] .valtext').length,
};
};
const complete = (snapshot) => snapshot.exactSpace === 'home'
&& snapshot.activeSpace === 'home' && snapshot.rooms > 0 && snapshot.decor > 0
&& snapshot.walls > 0 && snapshot.glow > 0 && snapshot.devices > 0 && snapshot.values > 0;
const waitForCard = async (card, calls, kiosk = false) => {
const until = performance.now() + 9000;
while (performance.now() < until) {
const snapshot = layerSnapshot(card);
const allSubscriptionsTried = ['houseplan_config_updated', 'houseplan_trail_updated', 'houseplan_layout_updated']
.every((event) => calls.events[event] === 1);
if (card._loadOk && !card._loading && card._booting === false
&& allSubscriptionsTried && complete({
...snapshot,
activeSpace: kiosk ? 'home' : snapshot.activeSpace,
})) return snapshot;
await sleep(25);
}
throw new Error(`readonly card did not settle: ${JSON.stringify({
state: layerSnapshot(card), calls, space: card._space, loadOk: card._loadOk,
})}`);
};
const mount = async (title, options = {}) => {
const runtime = makeHass(options.subscriptionMode);
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title, kiosk: !!options.kiosk });
host.appendChild(card);
card.hass = runtime.hass;
const snapshot = await waitForCard(card, runtime.calls, !!options.kiosk);
return { card, snapshot, ...runtime };
};
const cold = await mount('Readonly cold start');
out.coldSelectsExactSpace = cold.card._space === 'home'
&& cold.snapshot.exactSpace === 'home' && cold.snapshot.activeSpace === 'home';
out.coldRendersAllSpatialLayers = complete(cold.snapshot);
out.readOnlyStaysReadOnly = cold.card._canEdit === false
&& !(cold.card.shadowRoot || cold.card.renderRoot).querySelector('.modetab');
out.allSubscriptionsAttempted = ['houseplan_config_updated', 'houseplan_trail_updated', 'houseplan_layout_updated']
.every((event) => cold.calls.events[event] === 1);
out.allowedSubscriptionAdopted = typeof cold.card._unsubLayout === 'function'
&& !cold.card._unsubCfg && !cold.card._unsubTrail;
out.cacheWritten = !!localStorage.getItem('houseplan_card_cfg_v1');
const beforeClick = layerSnapshot(cold.card);
cold.card._pickSpace('home');
await cold.card.updateComplete;
out.activeTabClickIsNoop = JSON.stringify(layerSnapshot(cold.card)) === JSON.stringify(beforeClick);
await sleep(700);
out.optionalFailureNoFullRetry = cold.calls.configGets === 1;
cold.card.remove();
await sleep(20);
out.successfulSubscriptionCleanedUp = cold.calls.unsubscribed.includes('houseplan_layout_updated');
const warm = await mount('Readonly cold start');
out.warmRemountKeepsCompleteSpace = complete(warm.snapshot) && warm.card._space === 'home';
warm.card.remove();
await sleep(20);
HP._warmBootReset?.();
const reload = await mount('Readonly simulated reload');
out.cachedReloadKeepsCompleteSpace = complete(reload.snapshot) && reload.card._space === 'home';
reload.card.remove();
await sleep(20);
HP._warmBootReset?.();
localStorage.removeItem('houseplan_card_cfg_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const kiosk = await mount('Readonly cold kiosk', { kiosk: true, subscriptionMode: 'reject-all' });
const kioskRoot = kiosk.card.shadowRoot || kiosk.card.renderRoot;
out.coldKioskCompleteWithoutHeaderAction = kiosk.card._space === 'home'
&& kiosk.snapshot.exactSpace === 'home' && kiosk.snapshot.rooms > 0
&& kiosk.snapshot.decor > 0 && kiosk.snapshot.walls > 0 && kiosk.snapshot.glow > 0
&& kiosk.snapshot.devices > 0 && getComputedStyle(kioskRoot.querySelector('.hdr')).display === 'none';
out.kioskCacheWritten = !!localStorage.getItem('houseplan_card_cfg_v1');
kiosk.card.remove();
return out;
}, fixture);
checkAll(result);
await finish(browser, result);