mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
318 lines
20 KiB
JavaScript
318 lines
20 KiB
JavaScript
/** 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;
|
||
}
|