Files
houseplan-card/demo/smoke_summary_panel.mjs
T
claude[bot] 2f41c0b29e test(adoption): ownership lint, post-write smoke, harness seam and docs (#500)
`test/config-adoption-ownership.test.mjs` pins identity writes to the owner
and ratchets body staging (AC1/AC2). `demo/smoke_post_write_adoption.mjs`
drives space/delete (both runtimes), Optimize Undo and Import apply with a
concurrent backdrop change between the write and the re-read (AC4). Smokes
that seed revisions from outside the card keep working through the
`seedIdentity` harness seam behind the card's delegate setters. The initial
View ceiling is re-centred with the measured fact; ARCHITECTURE.md gets the
boundary paragraph.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00

592 lines
24 KiB
JavaScript

import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 960, height: 640 });
await page.waitForFunction(() => {
const card = window.__card;
const root = card?.shadowRoot || card?.renderRoot;
return !!root?.querySelector('.summary-control');
});
const initial = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const stageBefore = root().querySelector('.stage').getBoundingClientRect();
const buttons = root().querySelectorAll('.summary-control button');
buttons[1].click();
await card.updateComplete;
// Static containment is measured after entry. The focused #505 smoke
// separately samples opacity/translation and retained DOM during motion.
await new Promise((resolve) => setTimeout(resolve, 320));
const overlay = root().querySelector('.summary-overlay');
const stageAfter = root().querySelector('.stage').getBoundingClientRect();
const box = overlay?.getBoundingClientRect();
return {
splitControl: buttons.length === 2,
togglePersistsIntent: buttons[1].getAttribute('aria-pressed') === 'true',
rightOnWideStage: overlay?.classList.contains('right') === true,
overlayDoesNotResizeStage: Math.abs(stageBefore.width - stageAfter.width) < 0.5
&& Math.abs(stageBefore.height - stageAfter.height) < 0.5,
overlayInsideStage: !!box && box.left >= stageAfter.left - 1 && box.right <= stageAfter.right + 1
&& box.top >= stageAfter.top - 1 && box.bottom <= stageAfter.bottom + 1,
readOnlySurface: !!overlay && overlay.querySelectorAll('button, input, select, textarea').length === 0,
hasDefaultRows: (overlay?.querySelectorAll('.summary-value').length || 0) === 3,
};
});
// The stage respects the viewport's available height; an inline 900px height
// cannot force it beyond the viewport. Keep the viewport landscape while
// giving the deliberately narrower stage enough real height to be portrait.
await page.setViewportSize({ width: 1200, height: 1000 });
await page.evaluate(() => {
const card = window.__card;
const root = card.shadowRoot || card.renderRoot;
document.querySelector('#host').style.width = '560px';
root.querySelector('.stage').style.height = '900px';
card._summary.resized();
});
await page.waitForFunction(() => {
const card = window.__card;
const root = card?.shadowRoot || card?.renderRoot;
return root?.querySelector('.summary-overlay')?.classList.contains('bottom') === true;
});
const bottomOnTallStage = await page.evaluate(() => {
const card = window.__card;
const root = card.shadowRoot || card.renderRoot;
const stage = root.querySelector('.stage').getBoundingClientRect();
const overlay = root.querySelector('.summary-overlay').getBoundingClientRect();
return Math.abs((overlay.left + overlay.right) / 2 - (stage.left + stage.right) / 2) < 2;
});
await page.evaluate(() => {
const card = window.__card;
const root = card.shadowRoot || card.renderRoot;
document.querySelector('#host').style.width = '270px';
root.querySelector('.stage').style.height = '500px';
card._summary.resized();
});
await page.waitForFunction(() => {
const card = window.__card;
const root = card?.shadowRoot || card?.renderRoot;
return !root?.querySelector('.summary-overlay')
&& root?.querySelector('.summary-control button:last-child')?.getAttribute('aria-pressed') === 'true';
});
const smallCardKeepsLocalIntent = await page.evaluate(() => {
const card = window.__card;
const root = card.shadowRoot || card.renderRoot;
const toggle = root.querySelector('.summary-control button:last-child');
return toggle?.title === card._summary?.['t']?.('summary.hidden_small')
|| /space|мест|Platz|espace/i.test(toggle?.title || '');
});
await page.setViewportSize({ width: 960, height: 640 });
await page.evaluate(() => {
const card = window.__card;
const root = card.shadowRoot || card.renderRoot;
document.querySelector('#host').style.width = '780px';
root.querySelector('.stage').style.height = '640px';
card._summary.resized();
});
await page.waitForFunction(() => {
const card = window.__card;
const root = card?.shadowRoot || card?.renderRoot;
return root?.querySelector('.summary-overlay.right');
});
const pickerWitnessSetup = await page.evaluate(async () => {
const card = window.__card;
const states = {};
for (let index = 0; index < 10_000; index++) {
const id = `sensor.summary_${String(index).padStart(5, '0')}`;
states[id] = {
entity_id: id, state: String(index),
attributes: { friendly_name: `Summary reading ${index}` },
};
}
const blocks = Array.from({ length: 10 }, (_, blockIndex) => ({
id: `block-${blockIndex}`, title: `Block ${blockIndex}`, visible: true,
scope: { type: 'all' },
values: Array.from({ length: 20 }, (_, valueIndex) => {
const index = blockIndex * 20 + valueIndex;
return {
id: `value-${index}`, label: `Value ${index}`,
source: { type: 'entity', entity_id: `sensor.summary_${String(index).padStart(5, '0')}` },
};
}),
}));
card._serverCfg = {
...card._serverCfg,
settings: { ...(card._serverCfg.settings || {}), summary_panel: {
version: 1, title: 'Picker witness', show_on_mobile: true, blocks,
} },
};
card._settings = card._serverCfg.settings;
card.hass = { ...card.hass, states };
card.requestUpdate();
await card.updateComplete;
card._haSummaryPanelApi = 1;
await card._summary.openDialog();
await card.updateComplete;
card._summary.dialog = null;
card.requestUpdate();
await card.updateComplete;
const extras = [];
for (let index = 0; index < 2; index++) {
const extra = document.createElement('houseplan-card');
extra.style.display = 'none';
extra.hass = card.hass;
extra.setConfig({ ...(card._config || {}), type: 'custom:houseplan-card' });
document.body.append(extra);
// #506: the warm summary runtime now exists synchronously; the readiness
// this witness needs is the server config load, which stays asynchronous.
for (let attempt = 0; attempt < 400 && !(extra._summary && extra._loadOk); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
extra._serverCfg = structuredClone(card._serverCfg);
extra._settings = extra._serverCfg.settings;
extra._haSummaryPanelApi = 1;
extra._serverCanWrite = true;
await extra._summary.openDialog();
extra._summary.openSource('block-0', 'value-0');
await extra.updateComplete;
extras.push(extra);
}
window.__summaryWitnessExtras = extras;
const cards = [card, ...extras];
return {
cards: cards.length,
rows: blocks.reduce((sum, block) => sum + block.values.length, 0),
states: Object.keys(states).length,
everyCardHasFullIndex: cards.every((entry) => entry._summary.entityIndex?.entries.length === 10_000),
extraCardsBoundOnePicker: extras.every((entry) => {
const root = entry.shadowRoot || entry.renderRoot;
return root.querySelectorAll('.summary-source-picker').length === 1
&& root.querySelectorAll('.summary-source-results button').length <= 104;
}),
};
});
await page.evaluate(() => {
const card = window.__card;
const root = card.shadowRoot || card.renderRoot;
// The standalone harness has no backend capability handshake. Backend
// validation is covered separately; advertise the supported API here so
// this browser witness exercises the complete shared-settings form.
card._haSummaryPanelApi = 1;
root.querySelector('.summary-control button:first-child').click();
});
await page.waitForFunction(() => {
const card = window.__card;
const root = card?.shadowRoot || card?.renderRoot;
return !!root?.querySelector('hp-dialog .summary-editor');
});
const settings = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const editor = root().querySelector('hp-dialog .summary-editor');
const actionTargets = [...editor.querySelectorAll(
"button, input:not([type='checkbox']), select, .summary-drag",
)];
const switchTargets = [...editor.querySelectorAll('.summary-switch')];
const source = editor.querySelector('.summary-source');
const closedHasNoEntityOptions = editor.querySelectorAll('option[value^="entity:"]').length === 0;
source.click();
await card.updateComplete;
const picker = root().querySelector('.summary-source-picker');
const search = picker.querySelector('[data-summary-picker-search]');
const inputSamples = [];
for (let index = 0; index < 23; index++) {
const started = performance.now();
search.value = `Summary reading ${index % 10}`;
search.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await card.updateComplete;
if (index >= 3) inputSamples.push(performance.now() - started);
}
inputSamples.sort((a, b) => a - b);
const inputP95 = inputSamples[Math.ceil(inputSamples.length * .95) - 1];
search.value = 'sensor.summary_09999';
search.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await card.updateComplete;
const refreshedPicker = root().querySelector('.summary-source-picker');
const oneActivePicker = root().querySelectorAll('.summary-source-picker').length === 1;
const exact = [...refreshedPicker.querySelectorAll('.summary-source-results button')]
.find((button) => button.textContent.includes('sensor.summary_09999'));
const boundedPicker = refreshedPicker.querySelectorAll('.summary-source-results button').length <= 104;
exact?.click();
await card.updateComplete;
const cancel = [...root().querySelectorAll('hp-dialog [slot="footer"] button')]
.find((button) => button.textContent.trim() === card._t('btn.cancel'));
const samples = [];
for (let index = 0; index < 23; index++) {
card._summary.dialog = null;
const started = performance.now();
await card._summary.openDialog();
await card.updateComplete;
if (index >= 3) samples.push(performance.now() - started);
}
samples.sort((a, b) => a - b);
const openP95 = samples[Math.ceil(samples.length * .95) - 1];
const result = {
editorLoadedOnDemand: !!editor,
sharedAndLocalControls: !!editor?.querySelector('#summary-panel-title')
&& !!editor.querySelector('input[data-summary-local-show]')
&& !!editor.querySelector('input[data-summary-mobile-show]'),
obsoleteSizeControlsAbsent: editor.querySelectorAll(
'input[type="range"], .summary-local-sizes, .summary-sizes-title, .summary-size-reset',
).length === 0,
closedRowsHaveNoEntityOptions: closedHasNoEntityOptions,
oneActivePicker,
pickerClosesAfterSelection: root().querySelectorAll('.summary-source-picker').length === 0,
boundedPicker,
fullIndexReachableByExactSearch: !!exact,
loadedFormOpenP95Under250ms: openP95 <= 250,
pickerInputP95Under50ms: inputP95 <= 50,
metrics: { openP95Ms: openP95, inputP95Ms: inputP95, samples: inputSamples.length },
editorTouchTargets: [...actionTargets, ...switchTargets].every((target) => {
const box = target.getBoundingClientRect();
return box.width >= 44 && box.height >= 44;
}),
};
cancel?.click();
return result;
});
await page.evaluate(() => {
for (const extra of window.__summaryWitnessExtras || []) extra.remove();
window.__summaryWitnessExtras = [];
});
const indexInvalidation = await page.evaluate(async () => {
const card = window.__card;
const runtime = card._summary;
await runtime.openDialog();
await card.updateComplete;
const initial = runtime.entityIndex.rebuilds;
card.hass = { ...card.hass, states: {
...card.hass.states,
'sensor.summary_00000': { ...card.hass.states['sensor.summary_00000'], state: 'changed' },
} };
await card.updateComplete;
const afterValue = runtime.entityIndex.rebuilds;
card.hass = { ...card.hass, states: {
...card.hass.states,
'sensor.summary_added': {
entity_id: 'sensor.summary_added', state: '1', attributes: { friendly_name: 'Added' },
},
} };
await card.updateComplete;
const afterAdd = runtime.entityIndex.rebuilds;
card.hass = { ...card.hass, states: {
...card.hass.states,
'sensor.summary_00001': {
...card.hass.states['sensor.summary_00001'], attributes: { friendly_name: 'Renamed' },
},
} };
await card.updateComplete;
const afterRename = runtime.entityIndex.rebuilds;
runtime.dialog = null;
card.requestUpdate();
return {
stateValueDoesNotRebuildIndex: afterValue === initial,
addRebuildsIndexOnce: afterAdd === initial + 1,
renameRebuildsIndexOnce: afterRename === afterAdd + 1,
counts: { initial, afterValue, afterAdd, afterRename },
};
});
const responsiveForm = async ({ width, language, dark, canWrite, kiosk }) => {
await page.setViewportSize({ width, height: 760 });
return page.evaluate(async ({ language, dark, canWrite, kiosk }) => {
const card = window.__card;
document.documentElement.style.fontSize = '200%';
document.documentElement.toggleAttribute('dark', dark);
card._config = { ...card._config, language, kiosk };
card._serverCanWrite = canWrite;
card._summary.updated();
await card._summary.openDialog();
await card.updateComplete;
const root = card.shadowRoot || card.renderRoot;
const editor = root.querySelector('hp-dialog .summary-editor');
const footer = root.querySelector('hp-dialog [slot="footer"]');
const editorBox = editor.getBoundingClientRect();
const controls = [...editor.querySelectorAll(
'button, input:not([type="checkbox"]), select, .summary-switch, .summary-drag',
), ...root.querySelectorAll('hp-dialog [slot="footer"] button')];
const result = {
noHorizontalOverflow: editor.scrollWidth <= editor.clientWidth + 1,
controlsInsideViewport: controls.length >= 3 && controls.every((control) => {
const box = control.getBoundingClientRect();
return box.left >= -1 && box.right <= innerWidth + 1 && box.width >= 44 && box.height >= 44;
}),
footerReachable: !!footer && footer.getBoundingClientRect().left >= -1
&& footer.getBoundingClientRect().right <= innerWidth + 1,
editorInsideViewport: editorBox.left >= -1 && editorBox.right <= innerWidth + 1,
localOnlyMatchesRole: canWrite && !kiosk
? !editor.querySelector('.summary-local-hint') : !!editor.querySelector('.summary-local-hint'),
bounds: { left: editorBox.left, right: editorBox.right, viewport: innerWidth },
};
card._summary.dialog = null;
card.requestUpdate();
return result;
}, { language, dark, canWrite, kiosk });
};
const responsiveAdmin = await responsiveForm({
width: 320, language: 'ru', dark: true, canWrite: true, kiosk: false,
});
const responsiveHousehold = await responsiveForm({
width: 390, language: 'en', dark: false, canWrite: false, kiosk: false,
});
const responsiveKiosk = await responsiveForm({
width: 320, language: 'ru', dark: true, canWrite: true, kiosk: true,
});
await page.setViewportSize({ width: 960, height: 640 });
await page.evaluate(async () => {
const card = window.__card;
document.documentElement.style.fontSize = '';
document.documentElement.removeAttribute('dark');
card._config = { ...card._config, language: 'en', kiosk: false };
card._serverCanWrite = true;
card._summary.updated();
await card.updateComplete;
});
const liveSetup = await page.evaluate(async () => {
const card = window.__card;
const panel = {
version: 1, title: 'Live summary', show_on_mobile: true,
blocks: [{ id: 'live', title: 'Sensors', visible: true, scope: { type: 'all' }, values: [
{ id: 'summary-only', label: 'Summary only',
source: { type: 'entity', entity_id: 'sensor.summary_only' } },
] }],
};
card._serverCfg = {
...card._serverCfg,
settings: { ...(card._serverCfg.settings || {}), summary_panel: panel },
};
card.hass = {
...card.hass,
states: {
...card.hass.states,
'sensor.summary_only': {
entity_id: 'sensor.summary_only', state: '111', attributes: { unit_of_measurement: 'ppm' },
},
},
};
card.requestUpdate();
await card.updateComplete;
return card._renderDeviceSnapshot?.entityIds.includes('sensor.summary_only') === true;
});
await page.waitForFunction(() => {
const card = window.__card;
const root = card.shadowRoot || card.renderRoot;
return [...root.querySelectorAll('.summary-value strong')]
.some((node) => node.textContent.trim() === '111 ppm');
});
const liveState = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const value = () => root().querySelector('.summary-overlay .summary-value strong')
?.textContent.trim() || '';
await new Promise((resolve) => setTimeout(resolve, 500));
await card.updateComplete;
const originalUpdated = card.updated.bind(card);
let updateCount = 0;
card.updated = (changed) => { updateCount++; return originalUpdated(changed); };
const modelBefore = card._model;
const epochBefore = card._cfgEpoch;
const layoutBefore = card._layoutRev;
const beforeUnrelated = updateCount;
card.hass = {
...card.hass,
states: {
...card.hass.states,
'sensor.unrelated_490': { entity_id: 'sensor.unrelated_490', state: 'changed', attributes: {} },
},
};
await new Promise((resolve) => setTimeout(resolve, 50));
const unrelatedRenders = updateCount - beforeUnrelated;
const beforeRelevant = updateCount;
card.hass = {
...card.hass,
states: {
...card.hass.states,
'sensor.summary_only': {
...card.hass.states['sensor.summary_only'], state: '222',
},
},
};
await card.updateComplete;
const changed = value();
const relevantRenders = updateCount - beforeRelevant;
card.hass = {
...card.hass,
states: {
...card.hass.states,
'sensor.summary_only': {
...card.hass.states['sensor.summary_only'], state: 'unavailable', attributes: {},
},
},
};
await card.updateComplete;
const unavailable = value();
const missingStates = { ...card.hass.states };
delete missingStates['sensor.summary_only'];
card.hass = { ...card.hass, states: missingStates };
await card.updateComplete;
const missing = value();
card.hass = {
...card.hass,
states: {
...card.hass.states,
'sensor.summary_only': {
entity_id: 'sensor.summary_only', state: '333', attributes: { unit_of_measurement: 'ppm' },
},
},
};
await card.updateComplete;
const recovered = value();
card.updated = originalUpdated;
return {
unrelatedTickSkipped: unrelatedRenders === 0,
relevantTickRenderedOnce: relevantRenders === 1,
valueUpdated: changed === '222 ppm',
unavailableUpdated: unavailable === 'unavailable',
missingUpdated: missing === card._summary.t('summary.unavailable'),
recoveredUpdated: recovered === '333 ppm',
stateTicksKeepGeometry: card._model === modelBefore && card._cfgEpoch === epochBefore
&& card._layoutRev === layoutBefore,
};
});
const recovery = await page.evaluate(async () => {
const card = window.__card;
const runtime = card._summary;
const originalHass = card.hass;
const originalCallWS = originalHass.callWS;
const originalPrepareImage = card._signer.prepareImage.bind(card._signer);
// #500: adoption lives in the identity owner; observe it there.
const adoption = card._adoption;
const originalAdoptResponses = adoption.adoptResponses.bind(adoption);
const recoveryOrder = [];
const concurrentBackdrop = 'media-source://image/summary-recovery-490';
card._signer.prepareImage = async (_hass, href) => {
recoveryOrder.push(`prepare:${href}`);
return true;
};
adoption.adoptResponses = (...args) => {
recoveryOrder.push('adopt');
return originalAdoptResponses(...args);
};
const writes = [];
let serverConfig = structuredClone(card._serverCfg);
let serverRev = card._cfgRev;
let firstWrite = true;
let rejectConflict = false;
card.hass = {
...originalHass,
callWS: async (message) => {
if (message.type === 'houseplan/config/set') {
writes.push(structuredClone(message));
if (rejectConflict) throw Object.assign(new Error('synthetic conflict'), { code: 'conflict' });
if (firstWrite) {
firstWrite = false;
serverConfig = structuredClone(message.config);
serverConfig.spaces[0] = {
...serverConfig.spaces[0],
title: 'Concurrent title kept',
plan_url: concurrentBackdrop,
};
serverConfig.settings = { ...serverConfig.settings, concurrent_guard_490: 'kept' };
serverRev = message.expected_rev + 2;
throw new Error('synthetic lost ACK');
}
serverConfig = structuredClone(message.config);
serverRev++;
return { rev: serverRev };
}
if (message.type === 'houseplan/config/get') return {
config: structuredClone(serverConfig), rev: serverRev,
can_write: true, summary_panel_api: 1,
};
return originalCallWS(message);
},
};
await card.updateComplete;
await runtime.openDialog();
runtime.dialog.draft.title = 'Saved despite lost ACK';
await runtime.saveDialog();
const recoveredConfig = card._serverCfg;
const recoveredRev = card._cfgRev;
const firstClosed = runtime.dialog === null;
await runtime.openDialog();
runtime.dialog.draft.title = 'Second summary write';
await runtime.saveDialog();
const secondWrite = writes[1];
rejectConflict = true;
await runtime.openDialog();
runtime.dialog.draft.title = 'Must remain a conflict';
await runtime.saveDialog();
const trueConflictStaysOpen = runtime.dialog?.conflict === true
&& !!runtime.dialog.error
&& card._serverCfg.settings.summary_panel.title === 'Second summary write';
runtime.dialog = null;
card.hass = { ...card.hass, callWS: originalCallWS };
card._signer.prepareImage = originalPrepareImage;
delete adoption.adoptResponses;
await card.updateComplete;
return {
lostAckClosesAsSuccess: firstClosed,
recoveryAdoptsWholeConfig: recoveredConfig.spaces[0].title === 'Concurrent title kept'
&& recoveredConfig.spaces[0].plan_url === concurrentBackdrop
&& recoveredConfig.settings.concurrent_guard_490 === 'kept',
recoveryPreparesBackdropBeforeAdoption:
recoveryOrder.indexOf(`prepare:${concurrentBackdrop}`) >= 0
&& recoveryOrder.indexOf('adopt') > recoveryOrder.indexOf(`prepare:${concurrentBackdrop}`),
recoveryAdoptsRevision: recoveredRev === writes[0].expected_rev + 2,
nextWriteUsesRecoveredRevision: secondWrite?.expected_rev === recoveredRev,
nextWritePreservesConcurrentChange: secondWrite?.config?.spaces?.[0]?.title === 'Concurrent title kept'
&& secondWrite?.config?.spaces?.[0]?.plan_url === concurrentBackdrop
&& secondWrite?.config?.settings?.concurrent_guard_490 === 'kept',
trueConflictStaysOpen,
};
});
const { metrics: pickerMetrics, ...settingsChecks } = settings;
const witness = {
...initial,
bottomOnTallStage,
smallCardKeepsLocalIntent,
...settingsChecks,
pickerWitnessHas200RowsAnd10000States:
pickerWitnessSetup.cards === 3 && pickerWitnessSetup.rows === 200
&& pickerWitnessSetup.states === 10_000 && pickerWitnessSetup.everyCardHasFullIndex
&& pickerWitnessSetup.extraCardsBoundOnePicker,
stateValueDoesNotRebuildIndex: indexInvalidation.stateValueDoesNotRebuildIndex,
addRebuildsIndexOnce: indexInvalidation.addRebuildsIndexOnce,
renameRebuildsIndexOnce: indexInvalidation.renameRebuildsIndexOnce,
responsiveAdmin: Object.entries(responsiveAdmin).filter(([key]) => key !== 'bounds').every(([, value]) => value),
responsiveHousehold: Object.entries(responsiveHousehold).filter(([key]) => key !== 'bounds').every(([, value]) => value),
responsiveKiosk: Object.entries(responsiveKiosk).filter(([key]) => key !== 'bounds').every(([, value]) => value),
summaryDependencyCaptured: liveSetup,
...liveState,
...recovery,
};
checkAll(witness);
await finish(browser, {
...witness,
pickerTimings: pickerMetrics,
pickerFixture: pickerWitnessSetup,
indexRebuilds: indexInvalidation,
responsive: { admin: responsiveAdmin, household: responsiveHousehold, kiosk: responsiveKiosk },
});