Files
houseplan-card/demo/smoke_preloader.mjs
T
Codex 43809178ee test: poll smoke_preloader's reduced-motion phase from Playwright, with navigation diagnostics
The beta.9 candidate failed the same phase twice in CI with "Resulting
promise was garbage collected" (runs 34346813552, 34347910231) while it
passes locally; a timer guard did not help, which points at a destroyed
context rather than a starved animation-frame chain. The wait now runs as
page.waitForFunction with raf polling, and the smoke logs frame navigations
and page crashes as `diagnostic …` lines so the next failure names its
cause. Verdict unchanged (animationName of the boot house, or 'missed').
Pre-release gate repair per PROCESS §11.4; locally OK ×2.

Issue: #506
User-Visible: no
Release: v1.73.0-beta.9
2026-09-09 15:05:46 +03:00

149 lines
6.8 KiB
JavaScript

// HP-1552: residual jump on first open in NORMAL (non-kiosk) mode. The stage is
// calc(100dvh - _hdrH) and _hdrH is measured from HA's chrome, which loads
// AFTER the card's first paint — so the plan painted, then jumped when the
// panels landed. The fix hides the plan behind a boot veil (pulsing house)
// until the height reads the same twice in a row (or ~600 ms). This smoke
// simulates the late HA panel: a spacer above the card grows 0 → 56 px at
// t=300 ms. Assertions: the veil is shown, and there is NOT A SINGLE frame
// where the plan is visible at a non-final stage height. Kiosk: no veil ever.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
// ---------- phase 1: normal mode, HA panel lands late ----------
const res = await page.evaluate(async () => {
const out = {};
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.append(spacer, c);
document.body.appendChild(wrap);
const frames = [];
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let grown = false;
await new Promise((done) => {
const sample = () => {
const t = performance.now() - t0;
if (!grown && t >= 300) {
grown = true; // the "HA panel" arrives: everything above the card grows
spacer.style.height = '56px';
window.dispatchEvent(new Event('resize'));
}
const stage = sr().querySelector('.stage');
if (stage) {
const veil = sr().querySelector('.bootveil');
const wrapEl = stage.querySelector(':scope > .zoomwrap');
const planHidden =
!wrapEl ||
getComputedStyle(wrapEl).visibility === 'hidden' ||
(veil && !veil.classList.contains('off') && getComputedStyle(veil).opacity !== '0');
frames.push({
t: Math.round(t),
planVisible: !planHidden,
veil: !!veil,
veilBg: veil ? getComputedStyle(veil).backgroundColor : null,
house: !!(veil && veil.querySelector('.boothouse path')),
stageH: stage.clientHeight,
});
} else {
frames.push(null);
}
if (t < 1400) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
const seen = frames.filter(Boolean);
const finalH = seen[seen.length - 1].stageH;
out.sampledFrames = seen.length > 40;
out.veilShownWhileSettling = seen.some((f) => f.veil && !f.planVisible && f.house);
out.veilIsDark = (() => {
const f = seen.find((x) => x.veilBg);
const m = f && f.veilBg.match(/(\d+),\s*(\d+),\s*(\d+)/);
return !!m && +m[1] + +m[2] + +m[3] < 300; // dark, not white/transparent
})();
// THE assertion: no frame may show the plan at a stale (pre-panel) height
const bad = seen.filter((f) => f.planVisible && Math.abs(f.stageH - finalH) > 2);
out.noVisibleFrameAtStaleHeight = bad.length === 0
? true
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms — the jump is visible`;
const shown = seen.filter((f) => f.planVisible);
out.planShownAfterSettle = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
out.veilGoneInTheEnd = !seen[seen.length - 1].veil;
out.heightReactedToPanel = finalH !== seen[0].stageH; // the simulated panel actually moved the stage
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
// ---------- phase 2: kiosk never shows the veil ----------
const kiosk = await page.evaluate(async () => {
const out = {};
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
c.hass = window.__card.hass;
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:700px;z-index:99';
document.body.appendChild(c);
const sr = () => c.shadowRoot || c.renderRoot;
let veilEver = false;
let planHiddenEver = false;
const t0 = performance.now();
await new Promise((done) => {
const sample = () => {
const stage = sr().querySelector('.stage');
if (stage) {
if (sr().querySelector('.bootveil')) veilEver = true;
const wrapEl = stage.querySelector(':scope > .zoomwrap');
if (wrapEl && getComputedStyle(wrapEl).visibility === 'hidden') planHiddenEver = true;
}
if (performance.now() - t0 < 900) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
out.kioskNoVeil = !veilEver;
out.kioskPlanNeverHidden = !planHiddenEver;
c.remove();
return out;
});
check('kioskNoVeil', kiosk.kioskNoVeil);
check('kioskPlanNeverHidden', kiosk.kioskPlanNeverHidden);
// ---------- phase 3: prefers-reduced-motion → the house does not pulse ----------
// The wait lives on the Playwright side: an in-page promise that only a
// requestAnimationFrame chain keeps alive was reported as "garbage collected"
// on a CI runner (#506 beta.9 candidate) while the same phase passed locally.
// Polling from outside cannot be collected, and a destroyed context or a
// navigation surfaces as its own error instead of a vanished promise.
page.on('framenavigated', (frame) => console.log('diagnostic navigation:', frame.url()));
page.on('crash', () => console.log('diagnostic page crash'));
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(() => {
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99';
document.body.appendChild(c);
window.__preloaderReduced = { card: c, started: performance.now() };
});
const reduced = await page.waitForFunction(() => {
const probe = window.__preloaderReduced;
const root = probe.card.shadowRoot || probe.card.renderRoot;
const house = root?.querySelector('.bootveil .boothouse');
if (house) return { anim: getComputedStyle(house).animationName };
return performance.now() - probe.started > 500 ? { anim: 'missed' } : null;
}, null, { polling: 'raf', timeout: 5000 }).then((handle) => handle.jsonValue());
await page.evaluate(() => { window.__preloaderReduced.card.remove(); delete window.__preloaderReduced; });
check('reducedMotionStaticHouse', reduced.anim, 'none');
await finish(browser, { ...res, ...kiosk, ...reduced });