diff --git a/demo/capture_summary_panel_505.mjs b/demo/capture_summary_panel_505.mjs index 3ec946d0..694798e7 100644 --- a/demo/capture_summary_panel_505.mjs +++ b/demo/capture_summary_panel_505.mjs @@ -14,10 +14,11 @@ 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'].includes(arg)) { + 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}`); - options[{ '--output': 'output', '--ha-cache': 'cacheDir', '--ha-wheel': 'wheelPath' }[arg]] = resolve(value); + 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. @@ -31,6 +32,7 @@ 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.', @@ -234,8 +236,37 @@ async function capture(scenario) { await shot('product', 'control', page.locator('.summary-control')); } if (scenario.language === 'de') { - await page.locator('.summary-editor').evaluate((node) => { node.scrollTop = node.scrollHeight; }); + // 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 }) => { @@ -276,7 +307,9 @@ async function capture(scenario) { try { await probe(); - if (!options.probeOnly) for (const scenario of [...panelCases, ...settingCases, ...edgeCases]) await capture(scenario); + 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) { diff --git a/demo/helpers/README-ha-dialog-505.md b/demo/helpers/README-ha-dialog-505.md index b72f564e..a8876386 100644 --- a/demo/helpers/README-ha-dialog-505.md +++ b/demo/helpers/README-ha-dialog-505.md @@ -16,14 +16,17 @@ The second command requires an already fresh `npm run bundle:sync` result. It never builds or changes product/reference files, and refuses stale source fingerprints even when `HP_ALLOW_STALE_BUNDLE` is set. For reviewer work, use `--output "$RUNNER_TEMP/summary-panel-505"` to keep all evidence outside the checkout. +Use `--only edge-real-ha-text-200-long-de-390` for a named focused recapture; +comma-separated names are accepted and recorded as a partial selection. The first explicit run downloads the official 124,294,469-byte `home-assistant-frontend==20260729.7` wheel from the exact allowlisted PyPI file URL; version, size and SHA-256 are pinned in `ha-dialog-assets.mjs`, checked before extraction, and checked against `tests_backend/requirements.txt`. The wheel is **not installed**. Only allowlisted modern JS/static assets are -extracted to the OS temporary cache. Existing extracted bytes are hash-checked -against the generated local cache manifest each run. Use a new cache directory +extracted to the OS temporary cache. The deterministic cache manifest has its +own pinned hash; existing extracted bytes are then checked against its per-file +hashes each run, so edited cache metadata is not trusted. Use a new cache directory after corruption; the tool does not delete existing data. To reuse an already downloaded official wheel or select a disposable cache: @@ -70,6 +73,9 @@ on both sides. Reference displayed values are normalized to measured product values **only as fixture text**, not CSS; original reference files stay intact. The archived prototype UI is Russian; German stress changes fixture data and product locale, not the archived prototype UI translation. +Long-source start/end images use `scrollIntoView` and record the actual composed +scroll owner plus source/label visibility between header and footer. This matters +because genuine HA scrolls its shadow body, not necessarily `.summary-editor`. The 800×500 case is explicitly an effective-viewport proxy for a 1600×1000 display at 200%, **not actual browser zoom**. Separate 32px root-font cases diff --git a/demo/helpers/ha-dialog-assets.mjs b/demo/helpers/ha-dialog-assets.mjs index 1b4ad8d1..ce3c5070 100644 --- a/demo/helpers/ha-dialog-assets.mjs +++ b/demo/helpers/ha-dialog-assets.mjs @@ -11,6 +11,9 @@ import { parseAst } from 'rollup/parseAst'; export const HA_DIALOG_PIN = Object.freeze({ version: '20260729.7', bytes: 124294469, sha256: 'ba01782297e9506d3185f99a74d829f3811df322623848af21bcc0b8ea834c12', + // Deterministic manifest generated from that authenticated wheel, including + // extracted-file hashes and the module index. Cache metadata is not trusted. + cacheManifestSha256: '6fd75eb27f8a660c57d173d7b9b496d17f96e404c48adba7faeef1a3d391d831', url: 'https://files.pythonhosted.org/packages/22/84/f117626ac7db42d34341aa2795cd2bd2f84e7cc89ecdf0117bf4cfa2f1e6/home_assistant_frontend-20260729.7-py3-none-any.whl', app: 'app.d53ce8172fc8c85d.js', module: '25395', entry: 'o(91535);', }); @@ -67,7 +70,9 @@ export async function prepareHaDialogAssets({ mkdirSync(cache, { recursive: true }); const manifestPath = join(cache, 'manifest.json'); if (existsSync(manifestPath)) { - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + const manifestBytes = readFileSync(manifestPath); + if (digest(manifestBytes) !== HA_DIALOG_PIN.cacheManifestSha256) throw new Error('HA fixture cache manifest hash mismatch'); + const manifest = JSON.parse(manifestBytes); if (manifest.version !== pin || manifest.sha256 !== HA_DIALOG_PIN.sha256 || !manifest.files?.length || !manifest.modules?.[HA_DIALOG_PIN.module]) throw new Error('Invalid HA fixture cache manifest'); for (const file of manifest.files) { @@ -123,6 +128,7 @@ export async function prepareHaDialogAssets({ const root = join(cache, 'hass_frontend'); const manifest = { version: pin, sha256: HA_DIALOG_PIN.sha256, url: HA_DIALOG_PIN.url, files, modules: moduleIndex(join(root, 'frontend_latest')) }; + if (digest(JSON.stringify(manifest)) !== HA_DIALOG_PIN.cacheManifestSha256) throw new Error('Rebuilt HA manifest differs from verified pin; refusing this cache'); writeFileSync(manifestPath, JSON.stringify(manifest)); return { root, manifest }; } diff --git a/demo/helpers/ha-dialog-fixture.mjs b/demo/helpers/ha-dialog-fixture.mjs index 3c56e472..e579117e 100644 --- a/demo/helpers/ha-dialog-fixture.mjs +++ b/demo/helpers/ha-dialog-fixture.mjs @@ -99,7 +99,7 @@ export async function launchHaDialogFixture({ .replace(/