Files
houseplan-card/demo/smoke_preloader_lifecycle.mjs
2026-08-28 05:40:28 +00:00

216 lines
9.9 KiB
JavaScript

// AUD-1552-01 + AUD-1552-02: adversarial lifecycle regressions for the
// first-open boot veil (HP-1552). Both scenarios FAIL on v1.55.2.
//
// A. disconnect/reconnect while booting (Lovelace rebuilds its DOM, a view
// switch remounts the card). v1.55.2 cleared the boot timer on disconnect
// but kept its truthy id, so the reconnect never restarted the watcher and
// the plan stayed hidden FOREVER. Now the veil lifecycle restarts from
// connectedCallback: the veil must lift no later than the hard cap.
// B. layout shifts before, between and after the old poll ticks (150, 300,
// 450, 590 ms). v1.55.2 revealed the plan after two equal reads at
// 200/400 ms, so a panel landing at 450+ ms jumped on a VISIBLE plan. Now
// the veil holds for the full protective window with trailing quiescence:
// not a single frame may show the plan at a non-final stage height.
// C. a shift AFTER the reveal (slower than the cap) must glide, not snap:
// for a short grace the stage height transitions and the plan refits
// along with it (no transition at all on v1.55.2 — instant jump).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
// ---------- A: detach before the first tick, reattach, veil must lift ----------
const rec = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
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;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.appendChild(c);
document.body.appendChild(wrap);
await sleep(50); // the first boot tick has not fired yet
out.bootingWhenDetached = c._booting === true;
c.remove(); // disconnect: timers die here
await sleep(30);
wrap.appendChild(c); // reconnect — the veil lifecycle must restart
const t0 = performance.now();
const sr = () => c.shadowRoot || c.renderRoot;
while (c._booting && performance.now() - t0 < 2000) await sleep(50);
out.revealMs = Math.round(performance.now() - t0);
out.bootSettledAfterReconnect = c._booting === false;
out.settledWithinCap = performance.now() - t0 < 1500; // hard cap 1200 + slack
await sleep(300); // let the fade finish
const stage = sr().querySelector('.stage');
const wrapEl = stage && stage.querySelector(':scope > .zoomwrap');
out.planVisibleAfterReconnect = !!wrapEl && getComputedStyle(wrapEl).visibility === 'visible';
out.noHpbootClass = !!stage && !stage.classList.contains('hpboot');
out.veilGone = !sr().querySelector('.bootveil');
// A detach during resume/mode motion kills the one RAF owner. The transient
// state must die with it, otherwise the same element comes back inert or
// permanently pinned to an intermediate camera.
await c._ensureEditorRuntime();
c._resumeSettling = true;
c._slide = 'left';
const from = c._currentModeVisual('view');
c._mode = 'plan';
if (from) c._modeTransition.start(from, {
...from,
presentedMode: 'plan',
editorChromeHeight: 80,
stageHeight: Math.max(1, from.stageHeight - 80),
architectureOpacity: 1,
backdropOpacity: 1,
editorWeight: 1,
viewWeight: 0,
toolbarContentOpacity: 1,
}, 'plan', 1000);
c.requestUpdate(); await c.updateComplete;
c.remove();
out.detachClearsResumeAndNav = c._resumeSettling === false
&& c._modeTransition.active === false
&& c._modeTransitionVisual === null && c._slide === '';
wrap.appendChild(c);
await c.updateComplete;
const stageAfterMotionReattach = sr().querySelector('.stage');
out.reattachHasNoStuckMotionClasses = !!stageAfterMotionReattach
&& !stageAfterMotionReattach.classList.contains('mode-transition')
&& !stageAfterMotionReattach.classList.contains('hpresume');
// A2: a second detach DURING the fade must not leave a zombie veil either
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c2 = document.createElement('houseplan-card');
c2.setConfig({ type: 'custom:houseplan-card' });
c2.hass = window.__card.hass;
wrap.appendChild(c2);
const t1 = performance.now();
while (c2._booting && performance.now() - t1 < 2000) await sleep(50);
// _bootFading is true for ~220 ms after the reveal — detach right inside it
c2.remove();
await sleep(30);
wrap.appendChild(c2);
await sleep(400);
const sr2 = c2.shadowRoot || c2.renderRoot;
out.fadeVeilGoneAfterReattach = !sr2.querySelector('.bootveil') && c2._bootFading === false;
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(rec)) check(k, v, k === 'revealMs' ? rec.revealMs : true);
// ---------- B: shifts at 150/300/450/590 ms — never a visible stale frame ----------
const shifts = [150, 300, 450, 590];
const outB = {};
for (const shiftMs of shifts) {
const res = await page.evaluate(async (shiftAt) => {
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 >= shiftAt) {
grown = true; // the "HA panel" arrives
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, stageH: stage.clientHeight });
}
if (t < 1900) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
const seen = frames;
const finalH = seen[seen.length - 1].stageH;
// 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.noStaleFrame = bad.length === 0
? true
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms`;
const shown = seen.filter((f) => f.planVisible);
out.planShown = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
out.heightReacted = finalH !== seen[0].stageH;
wrap.remove();
return out;
}, shiftMs);
outB[`shift${shiftMs}_noStaleFrame`] = res.noStaleFrame;
outB[`shift${shiftMs}_planShown`] = res.planShown;
outB[`shift${shiftMs}_heightReacted`] = res.heightReacted;
check(`shift${shiftMs}_noStaleFrame`, res.noStaleFrame);
check(`shift${shiftMs}_planShown`, res.planShown);
check(`shift${shiftMs}_heightReacted`, res.heightReacted);
}
// ---------- C: shift AFTER the reveal glides instead of snapping ----------
const late = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
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 sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(25);
out.revealed = c._booting === false;
await sleep(120); // clearly after the reveal, still inside the soft grace
const stage = sr().querySelector('.stage');
const h0 = stage.clientHeight;
spacer.style.height = '56px'; // VERY late panel — slower than the cap
window.dispatchEvent(new Event('resize'));
const heights = [];
await new Promise((done) => {
const t1 = performance.now();
const sample = () => {
heights.push(stage.clientHeight);
if (performance.now() - t1 < 700) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
const hEnd = heights[heights.length - 1];
out.heightMoved = Math.abs(hEnd - h0) > 30;
// the glide: intermediate frames strictly between the endpoints
const mid = heights.filter((h) => Math.abs(h - h0) > 4 && Math.abs(h - hEnd) > 4);
out.lateShiftGlides = mid.length >= 3
? true
: `only ${mid.length} intermediate frame(s): ${h0} -> ${hEnd} snapped`;
out.settledAtFinal = Math.abs(hEnd - (h0 - 56)) <= 2;
wrap.remove();
return out;
});
check('late_revealed', late.revealed);
check('late_heightMoved', late.heightMoved);
check('late_lateShiftGlides', late.lateShiftGlides);
check('late_settledAtFinal', late.settledAtFinal);
await finish(browser, { ...rec, ...outB, ...late });