mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
149 lines
6.8 KiB
JavaScript
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 });
|