Files
houseplan-card/demo/capture_summary_panel_505.mjs
T
2026-09-09 09:32:36 +03:00

318 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Explicit #505 diagnostic evidence, not a golden-baseline producer or smoke. */
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { HA_DIALOG_PIN, prepareHaDialogAssets } from './helpers/ha-dialog-assets.mjs';
import { launchHaDialogFixture } from './helpers/ha-dialog-fixture.mjs';
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const options = { output: join(repo, 'artifacts/summary-panel-505') };
for (let index = 2; index < process.argv.length; index++) {
const arg = process.argv[index];
if (arg === '--probe-only') options.probeOnly = true;
else if (['--output', '--ha-cache', '--ha-wheel', '--only'].includes(arg)) {
const value = process.argv[++index];
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
if (arg === '--only') options.only = value.split(',');
else options[{ '--output': 'output', '--ha-cache': 'cacheDir', '--ha-wheel': 'wheelPath' }[arg]] = resolve(value);
} else throw new Error(`Unknown option ${arg}`);
}
// Fail before the optional 124 MB download. Captures never bypass freshness.
const fingerprint = sourceFingerprint(repo);
if (!options.probeOnly) {
const manifest = JSON.parse(readFileSync(join(repo, 'demo/srv/assets/houseplan-assets.json')));
assert.equal(manifest.fingerprint, fingerprint, 'Stale demo bundle: run npm run bundle:sync before capturing');
}
mkdirSync(options.output, { recursive: true });
const report = {
issue: 505, diagnosticOnly: true, platform: process.platform,
sourceSha: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim(),
sourceFingerprint: fingerprint, probeOnly: Boolean(options.probeOnly),
selectedCases: options.only || 'full matrix',
haFrontend: HA_DIALOG_PIN.version, wheelSha256: HA_DIALOG_PIN.sha256,
boundaries: 'Loopback synthetic demo only. No core/auth/HA server. Official app loader exposed at runtime; component factories/CSS unmodified.',
comparison: 'Equivalent one block, three default system rows and one entity row. Reference row text is normalized to observed product values; reference CSS/files are unchanged. Floor-plan/fake HA chrome pixels are not parity targets.',
captures: [],
};
const saveReport = () => writeFileSync(join(options.output, 'report.json'), JSON.stringify(report, null, 2));
const assets = await prepareHaDialogAssets(options);
async function probe() {
const fixture = await launchHaDialogFixture({ assets });
try {
const { page } = fixture;
report.chromium = fixture.browser.version();
await page.goto(`${fixture.url}/ha505-probe.html`);
await page.waitForFunction(() => document.querySelector('ha-dialog')?.shadowRoot?.querySelector('wa-dialog')?.shadowRoot?.querySelector('dialog')?.matches(':modal'));
await page.waitForTimeout(300);
const read = () => page.evaluate(() => {
const ha = document.querySelector('ha-dialog');
const wa = ha.shadowRoot.querySelector('wa-dialog');
const surface = wa.shadowRoot.querySelector('dialog');
return { width: surface.getBoundingClientRect().width, modal: surface.matches(':modal'),
radius: getComputedStyle(surface).borderRadius, preset: ha.width,
inheritedMd: getComputedStyle(ha).getPropertyValue('--ha-dialog-width-md') };
});
const small = await read();
await page.evaluate(async () => { const ha = document.querySelector('ha-dialog'); ha.width = 'medium'; await ha.updateComplete; });
await page.waitForTimeout(50);
const medium = await read();
await page.evaluate(() => document.querySelector('#parent').style.setProperty('--ha-dialog-width-md', '920px'));
await page.waitForTimeout(50);
const inherited = await read();
await page.screenshot({ path: join(options.output, 'authentic-ha-width-920.png') });
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(100);
const mobile = await read();
await page.screenshot({ path: join(options.output, 'authentic-ha-mobile-390.png') });
assert.equal(small.width, 320); assert.equal(medium.width, 580);
assert.equal(inherited.width, 920); assert.equal(mobile.width, 390);
await fixture.assertClean();
report.probe = { small, medium, inherited, mobile, provenance: fixture.provenance,
pageErrors: fixture.errors, externalRequests: fixture.externalRequests, websocketAttempts: fixture.websocketAttempts };
saveReport();
} finally { await fixture.close(); }
}
const rowLabels = ['Количество устройств', 'Общая площадь комнат', 'Текущие дата и время', 'Температура кухни'];
const productConfig = {
version: 1, title: 'Сводная информация', show_on_mobile: true,
blocks: [{ id: 'block-1', title: 'Общее', visible: true, scope: { type: 'all' },
values: rowLabels.map((label, index) => ({ id: `value-${index + 1}`, label,
source: index < 3 ? { type: 'system', key: ['device_count', 'total_area', 'datetime'][index] }
: { type: 'entity', entity_id: 'sensor.kitchen_temperature' } })) }],
};
const referenceConfig = { title: productConfig.title, showOnMobile: true, blocks: [{
...productConfig.blocks[0], scope: { type: 'all', spaceId: null },
values: rowLabels.map((label, index) => ({ id: `value-${index + 1}`, label,
source: index < 3 ? { type: 'system', key: ['device_count', 'room_area', 'date_time'][index], name: label }
: { type: 'entity', entityId: 'sensor.kitchen_temperature' } })),
}] };
const panelCases = [
{ name: 'desktop-light', width: 1600, height: 1000, theme: 'light' },
{ name: 'desktop-dark', width: 1600, height: 1000, theme: 'dark' },
{ name: 'bottom-portrait', width: 800, height: 1100, theme: 'light' },
{ name: 'kiosk', width: 1600, height: 1000, theme: 'light', kiosk: true },
{ name: 'mobile-view', width: 390, height: 844, theme: 'light' },
];
const settingCases = ['native', 'real-ha'].flatMap((shell) => [
{ name: 'desktop', width: 1600, height: 1000 },
{ name: 'narrow-320', width: 320, height: 844 },
{ name: 'narrow-390', width: 390, height: 844 },
].flatMap((size) => ['light', 'dark'].map((theme) => ({ ...size,
name: `settings-${shell}-${size.name}-${theme}`, theme, shell, settings: true }))));
const edgeCases = ['native', 'real-ha'].flatMap((shell) => [
{ name: `edge-${shell}-effective-zoom-200`, width: 800, height: 500,
adaptation: '800×500 effective viewport proxy for 1600×1000 at 200%; not actual browser zoom' },
{ name: `edge-${shell}-text-200-long-de`, width: 1600, height: 1000, textSize: 32, language: 'de',
adaptation: 'Root font-size 32px enlarged-text stress, long German entity name/id; not browser zoom' },
{ name: `edge-${shell}-text-200-long-de-390`, width: 390, height: 844, textSize: 32, language: 'de',
adaptation: '390px plus root font-size32px and long German source' },
].map((scenario) => ({ ...scenario, shell, settings: true, theme: 'light' })));
function scenarioConfig(scenario, reference = false) {
const config = structuredClone(reference ? referenceConfig : productConfig);
if (scenario.language === 'de') {
config.title = 'Zusammenfassung'; config.blocks[0].title = 'Allgemein';
const row = config.blocks[0].values[3];
row.label = 'Küchentemperatur und langfristiger Messwert';
row.source = { type: 'entity', [reference ? 'entityId' : 'entity_id']:
'sensor.kueche_erdgeschoss_langfristige_temperaturmessung_mit_besonders_langer_quellenkennung_505' };
}
return config;
}
async function configureProduct(page, scenario) {
await page.waitForFunction(() => window.__card?._summary && window.__card?._model?.length > 0);
await assertFreshDemoBundle(page, repo);
await page.waitForFunction(() => window.__card._booting === false && !window.__card._bootFading);
await page.evaluate(async ({ scenario, config }) => {
const card = window.__card;
Object.assign(document.querySelector('#host').style, { width: '100%', height: '100vh', padding: '0', margin: '0', maxWidth: 'none' });
Object.assign(document.body.style, { margin: '0', padding: '0' });
card.panelHost = true;
card.setAttribute('panel-host', '');
Object.assign(card.style, { width: '100%', height: '100%' });
card.setConfig({ ...card._config, language: scenario.language || 'ru', kiosk: Boolean(scenario.kiosk) });
card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, summary_panel: config } };
card._cfgEpoch++;
card._haSummaryPanelApi = 1;
card._serverCanWrite = true;
card.narrow = scenario.width <= 450;
const dark = scenario.theme === 'dark';
const colors = dark ? ['#3ea6ff', '#e6e7eb', '#9aa4ad', '#202126', '#3a3d45', '#11151b', '#2b2d33']
: ['#0b73b8', '#202124', '#5f6368', '#ffffff', '#d7d9de', '#eef1f4', '#e7eaee'];
['primary-color', 'primary-text-color', 'secondary-text-color', 'card-background-color', 'divider-color', 'primary-background-color', 'secondary-background-color']
.forEach((key, index) => document.documentElement.style.setProperty(`--${key}`, colors[index]));
document.documentElement.style.setProperty('--ha-card-background', colors[3]);
document.documentElement.style.setProperty('--app-header-background-color', colors[3]);
document.documentElement.style.setProperty('--app-header-text-color', colors[1]);
document.documentElement.style.setProperty('--text-primary-color', '#ffffff');
document.documentElement.style.colorScheme = scenario.theme;
if (scenario.textSize) document.documentElement.style.fontSize = `${scenario.textSize}px`;
document.body.style.background = colors[5];
const entityId = config.blocks[0].values[3].source.entity_id;
card.hass = { ...card.hass, language: scenario.language || 'ru', locale: { ...card.hass.locale, language: scenario.language || 'ru' },
config: { ...card.hass.config, time_zone: 'Europe/Moscow', unit_system: { ...card.hass.config?.unit_system, length: 'm' } },
themes: { ...card.hass.themes, darkMode: dark },
states: { ...card.hass.states, [entityId]: { entity_id: entityId, state: '20.6', attributes: {
friendly_name: scenario.language === 'de' ? 'Küchentemperatur im Erdgeschoss mit langfristiger Messwertüberwachung und besonders langer Quellenbezeichnung' : 'Kitchen temperature', unit_of_measurement: '°C' } } },
};
card._summary.updated();
card._summary.saveLocal({ show: true });
card.requestUpdate();
await card.updateComplete;
card._summary.resized();
}, { scenario, config: scenarioConfig(scenario) });
await page.waitForFunction(() => window.__card._booting === false && !window.__card._bootFading && window.__card._devices.length > 0);
if (!scenario.settings) await page.locator('.summary-overlay').waitFor({ state: 'visible' });
await page.waitForTimeout(350);
if (scenario.settings) {
await page.evaluate(async () => {
const card = window.__card;
if (card._ensureEditorRuntime) assertRuntime(await card._ensureEditorRuntime());
function assertRuntime(ready) { if (!ready) throw new Error('Editor runtime failed to load'); }
await card._summary.openDialog();
await card.updateComplete;
});
await page.locator('hp-dialog[data-kind="summary"] .summary-editor').waitFor({ state: 'visible' });
await page.waitForTimeout(300);
}
}
async function capture(scenario) {
console.log(`Capturing ${scenario.name}`);
const fixture = await launchHaDialogFixture({ assets, authentic: scenario.shell === 'real-ha',
viewport: { width: scenario.width, height: scenario.height }, colorScheme: scenario.theme });
const record = { ...scenario, files: {} };
try {
const { page } = fixture;
// Let Date.now advance: the real card boot-veil quiescence uses it.
await page.clock.setSystemTime(new Date('2026-09-09T09:00:00Z'));
await page.goto(`${fixture.url}/product.html`);
await configureProduct(page, scenario);
const values = await page.evaluate((config) => config.blocks[0].values.map((value) => window.__card._summary.value(value)), scenarioConfig(scenario));
assert.equal(values.length, 4, 'Equivalent four-row product fixture');
record.product = await page.evaluate((settings) => {
const root = window.__card.shadowRoot;
const box = (node) => node ? { ...node.getBoundingClientRect().toJSON(), scrollWidth: node.scrollWidth, clientWidth: node.clientWidth } : null;
const panel = root.querySelector('.summary-overlay');
const hp = root.querySelector('hp-dialog[data-kind="summary"]');
const ha = hp?.shadowRoot.querySelector('ha-dialog');
const wa = ha?.shadowRoot.querySelector('wa-dialog');
const surface = wa?.shadowRoot.querySelector('dialog') || hp?.shadowRoot.querySelector('.surface');
const footer = hp?.querySelector('[slot="footer"]');
const editor = hp?.querySelector('.summary-editor');
const insideViewport = (node) => { const r = node?.getBoundingClientRect(); return !!r && r.left >= -1 && r.right <= innerWidth + 1 && r.top >= -1 && r.bottom <= innerHeight + 1; };
return { panel: box(panel), control: box(root.querySelector('.summary-control')), stage: box(root.querySelector('.stage')),
side: panel?.classList.contains('bottom') ? 'bottom' : 'right', rows: panel?.querySelectorAll('.summary-value').length,
settings: settings ? { surface: box(surface), editor: box(editor), footer: box(footer),
actualHA: Boolean(wa), modal: surface?.matches(':modal'), radius: getComputedStyle(surface).borderRadius,
border: getComputedStyle(surface).border, shadow: getComputedStyle(surface).boxShadow,
rootFont: getComputedStyle(document.documentElement).fontSize,
titleFont: getComputedStyle(hp.shadowRoot.querySelector('.title')).fontSize,
sourceRows: [...editor.querySelectorAll('.summary-source')].map((node) => ({ box: box(node), text: node.textContent.trim() })),
checks: { surfaceInsideViewport: insideViewport(surface), footerVisible: insideViewport(footer),
editorNoHorizontalOverflow: editor.scrollWidth <= editor.clientWidth + 1,
surfaceNoHorizontalOverflow: surface.scrollWidth <= surface.clientWidth + 1,
footerNoHorizontalOverflow: footer.scrollWidth <= footer.clientWidth + 1 },
close: box(ha?.shadowRoot.querySelector('ha-icon-button') || hp.shadowRoot.querySelector('.close')) } : null };
}, Boolean(scenario.settings));
const shot = async (side, area, locator) => {
const name = `${scenario.name}-${side}-${area}.png`;
if (locator) await locator.screenshot({ path: join(options.output, name) });
else await page.screenshot({ path: join(options.output, name) });
record.files[`${side}-${area}`] = name;
};
await shot('product', 'viewport');
if (scenario.settings) await shot('product', 'dialog', page.locator(scenario.shell === 'real-ha'
? 'hp-dialog[data-kind="summary"] ha-dialog wa-dialog dialog' : 'hp-dialog[data-kind="summary"] .surface'));
else {
await shot('product', 'panel', page.locator('.summary-overlay'));
await shot('product', 'control', page.locator('.summary-control'));
}
if (scenario.language === 'de') {
// The genuine HA owns scrolling in its shadow .body; the native wrapper
// owns it in the editor. Follow the composed tree instead of assuming.
const measureSource = async (align) => page.locator('.summary-source').last().evaluate((source, align) => {
const label = source.querySelector('strong');
(align === 'start' ? label : source).scrollIntoView({ block: align, inline: 'nearest', behavior: 'instant' });
const hp = source.closest('hp-dialog');
const ha = hp.shadowRoot.querySelector('ha-dialog');
const header = ha?.shadowRoot.querySelector('ha-dialog-header') || hp.shadowRoot.querySelector('.header');
const footer = hp.querySelector('[slot="footer"]');
let bandTop = Math.max(0, header?.getBoundingClientRect().bottom || 0);
let bandBottom = Math.min(innerHeight, footer.getBoundingClientRect().top);
const visible = (node) => { const box = node.getBoundingClientRect().toJSON();
const visibleHeight = Math.max(0, Math.min(box.bottom, bandBottom) - Math.max(box.top, bandTop));
return { box, visibleHeight, visibleFraction: visibleHeight / box.height }; };
const scrollOwners = [];
for (let node = source; node; node = node.assignedSlot || node.parentElement || node.getRootNode()?.host) {
if (node.scrollHeight > node.clientHeight + 1 && /auto|scroll/.test(getComputedStyle(node).overflowY)) {
const clip = node.getBoundingClientRect();
bandTop = Math.max(bandTop, clip.top); bandBottom = Math.min(bandBottom, clip.bottom);
scrollOwners.push({ tag: node.localName, class: node.className, scrollTop: node.scrollTop,
scrollHeight: node.scrollHeight, clientHeight: node.clientHeight });
}
}
return { align, bandTop, bandBottom, label: visible(label), source: visible(source), scrollOwners };
}, align);
record.product.longSourceStart = await measureSource('start');
assert.ok(record.product.longSourceStart.label.visibleHeight > 0, 'Long source label must actually appear between header and footer');
await shot('product', 'long-source-scrolled');
record.product.longSourceEnd = await measureSource('end');
assert.ok(record.product.longSourceEnd.source.visibleHeight > 0, 'Long source end must actually be visible');
await shot('product', 'long-source-end');
}
await fixture.assertClean();
await page.addInitScript(({ config, theme, kiosk, textSize }) => {
localStorage.setItem('houseplan-dashboard5-config', JSON.stringify(config));
localStorage.setItem('houseplan-dashboard5-enabled', 'true');
localStorage.setItem('houseplan-dashboard5-theme', theme);
localStorage.setItem('houseplan-dashboard5-kiosk', String(Boolean(kiosk)));
if (textSize) document.addEventListener('DOMContentLoaded', () => { document.documentElement.style.fontSize = `${textSize}px`; });
}, { config: scenarioConfig(scenario, true), theme: scenario.theme, kiosk: scenario.kiosk, textSize: scenario.textSize });
await page.goto(`${fixture.url}/reference/index.html`);
await page.locator('#dashboard3-panel').waitFor({ state: 'visible' });
await page.waitForTimeout(350);
await page.locator('.dashboard3-value dd').evaluateAll((nodes, text) => {
if (nodes.length !== text.length) throw new Error('Equivalent reference row count mismatch');
nodes.forEach((node, index) => { node.textContent = text[index]; });
}, values);
if (scenario.settings) {
await page.locator('.dashboard3-settings-toggle').click();
await page.locator('.dashboard3-settings-dialog__window').waitFor({ state: 'visible' });
}
record.reference = await page.evaluate(() => {
const box = (selector) => document.querySelector(selector)?.getBoundingClientRect().toJSON();
return { panel: box('#dashboard3-panel'), control: box('.dashboard5-summary-control'),
dialog: box('.dashboard3-settings-dialog__window'), normalizedValueText: [...document.querySelectorAll('.dashboard3-value dd')].map((node) => node.textContent) };
});
await shot('reference', 'viewport');
if (scenario.settings) await shot('reference', 'dialog', page.locator('.dashboard3-settings-dialog__window'));
else {
await shot('reference', 'panel', page.locator('#dashboard3-panel'));
await shot('reference', 'control', page.locator('.dashboard5-summary-control'));
}
await fixture.assertClean();
record.pageErrors = fixture.errors; record.externalRequests = fixture.externalRequests;
record.websocketAttempts = fixture.websocketAttempts;
report.captures.push(record); saveReport();
} finally { await fixture.close(); }
}
try {
await probe();
const cases = [...panelCases, ...settingCases, ...edgeCases];
if (options.only) for (const name of options.only) assert.ok(cases.some((scenario) => scenario.name === name), `Unknown case ${name}`);
if (!options.probeOnly) for (const scenario of cases.filter((scenario) => !options.only || options.only.includes(scenario.name))) await capture(scenario);
report.status = 'complete'; saveReport();
console.log(`Diagnostic evidence: ${join(options.output, 'report.json')}`);
} catch (error) {
report.status = 'failed'; report.error = error.stack || String(error); saveReport(); throw error;
}