Files
houseplan-card/demo/smoke_summary_first_paint.mjs
T
Codex 9b00002a5b test: allow the indivisible per-space masonry slice in the first-paint budget
The residual 150–200 ms tasks are one space's wallBodiesGeometry — a
single polyclip union that cannot be split — not the aggregate the issue
is about. The threshold now names them and leaves room for a slower CI
runner; the mutant that computes everything in one task still produces
~1.5 s and fails.

Issue: #509
User-Visible: no
2026-09-10 11:48:30 +03:00

193 lines
10 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #509: первый показ сводной панели на большом плане.
//
// Что здесь доказывается и почему смоком, а не юнитом. Обе половины дефекта
// видны только в живом кадре: «Source unavailable» вместо значений рисовался
// в первом же рендере панели, а подвисание возникало оттого, что агрегаты
// считались синхронно внутри render — юнит на чистой функции ни того, ни
// другого не замечает. План берётся тот же, на котором мерили в S2
// (demo/fixtures/large-house.mjs: 3 пространства, 60 комнат): на демо-доме
// расчёт дёшев и дефект не воспроизводится.
import { launch, check, finish } from './serve.mjs';
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
const fixture = makeLargeHouseFixture();
const BLOCK = {
id: 'first-paint', title: 'General', visible: true, scope: { type: 'all' },
values: [
{ id: 'devices', label: 'Devices', source: { type: 'system', key: 'device_count' } },
{ id: 'area', label: 'Floor area', source: { type: 'system', key: 'total_area' } },
{ id: 'clock', label: 'Date and time', source: { type: 'system', key: 'datetime' } },
],
};
/** Панель на большом плане: возвращает страницу и хелперы. */
async function openLargePlan(page, { reducedMotion = false } = {}) {
if (reducedMotion) await page.emulateMedia({ reducedMotion: 'reduce' });
await page.waitForFunction(() => !!window.__card?._summary);
return page.evaluate(async ({ config, block }) => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const settle = async () => { await card.updateComplete; await frame(); await card.updateComplete; };
card._config = { ...card._config, language: 'en', kiosk: false };
card._serverCanWrite = true;
card._haSummaryPanelApi = 1;
card.narrow = false;
const next = structuredClone(config);
next.settings = {
...next.settings,
summary_panel: { version: 1, title: 'Summary', show_on_mobile: true, blocks: [block] },
};
card._serverCfg = next;
card._settings = next.settings;
card._cfgEpoch = (card._cfgEpoch || 0) + 1;
card._sendConfigCandidate = async () => {};
card._summary.updated();
card.requestUpdate();
await settle();
window.__firstPaint = {
card, root, frame, settle,
overlay: () => root().querySelector('.summary-overlay'),
rows: () => [...(root().querySelector('.summary-overlay')?.querySelectorAll('.summary-value') || [])],
texts: () => [...(root().querySelector('.summary-overlay')?.querySelectorAll('.summary-value strong') || [])]
.map((node) => node.textContent.trim()),
pending: () => (root().querySelector('.summary-overlay')?.querySelectorAll('.summary-value-pending') || []).length,
};
return { ready: !!card._summary, spaces: card._serverCfg.spaces.length };
}, { config: fixture.config, block: BLOCK });
}
// --- (а) первый показ: скелеты вместо текста ошибки, кадр не заблокирован ---
{
const { page, browser } = await launch({ width: 1280, height: 900 });
const opened = await openLargePlan(page);
check('largePlanReady', opened.ready && opened.spaces === 3);
const first = await page.evaluate(async () => {
const f = window.__firstPaint;
const toggle = f.root().querySelector('.summary-control')?.querySelector('button:last-child');
// Долгие задачи главного потока за всё время показа: подвисание может
// случиться и не в первом кадре, а сразу после прихода ленивого чанка,
// и человек увидит ровно тот же фриз (#509 AC4).
//
// Считаются только задачи ПОСЛЕ того, как большой план дорисован: сама
// отрисовка 60 комнат — тоже длинная задача, но она не про эту задачу и
// на CI-раннере попадала в окно наблюдения (#509, первый прогон CI).
await new Promise((resolve) => {
let quiet = null;
const settleObserver = (() => {
try {
const observer = new PerformanceObserver(() => {
clearTimeout(quiet);
quiet = setTimeout(() => { observer.disconnect(); resolve(); }, 400);
});
observer.observe({ entryTypes: ['longtask'] });
quiet = setTimeout(() => { observer.disconnect(); resolve(); }, 400);
return observer;
} catch { resolve(); return null; }
})();
if (!settleObserver) resolve();
});
window.__longTasks = [];
try {
window.__longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) window.__longTasks.push(Math.round(entry.duration));
});
window.__longTaskObserver.observe({ entryTypes: ['longtask'] });
} catch { window.__longTaskObserver = null; }
const started = performance.now();
toggle?.click();
// Ровно один кадр: именно его и видел человек как «зависание».
await f.card.updateComplete;
await f.frame();
const blockedMs = performance.now() - started;
return {
blockedMs, rows: f.rows().length, pending: f.pending(), texts: f.texts(),
hasOverlay: !!f.overlay(),
};
});
check('overlayShownOnFirstFrame', first.hasOverlay && first.rows === 3);
// AC1: до готовности — скелеты; ни одна строка не врёт про недоступный источник.
check('skeletonsInsteadOfError', first.pending >= 2);
check('noUnavailableOnFirstFrame', !first.texts.some((text) => /unavailable/i.test(text)));
// AC4: первый кадр не съеден расчётом. Порог с большим запасом от измеренного
// (S2: до правки один только расчёт площади занимал секунды на этом плане).
check('firstFrameNotBlocked', first.blockedMs < 400);
// AC8: скелет живёт ограниченное время — значения приходят следом.
const settled = await page.evaluate(async () => {
const f = window.__firstPaint;
const started = performance.now();
const deadline = started + 4000;
while (performance.now() < deadline && f.pending() > 0) {
await new Promise((resolve) => setTimeout(resolve, 25));
await f.card.updateComplete;
}
window.__longTaskObserver?.takeRecords?.().forEach((entry) => window.__longTasks.push(Math.round(entry.duration)));
window.__longTaskObserver?.disconnect?.();
return {
waitedMs: performance.now() - started, pending: f.pending(), texts: f.texts(),
longest: window.__longTasks.length ? Math.max(...window.__longTasks) : 0,
tasks: window.__longTasks.slice(0, 8),
observed: !!window.__longTaskObserver,
};
});
check('skeletonReplacedByValues', settled.pending === 0);
check('valuesAreReal', settled.texts.every((text) => text && !/unavailable/i.test(text)));
check('replacedWithinBudget', settled.waitedMs < 6000);
// AC4: работа идёт порциями, а не одним куском. Остаточные 150–200 мс — это
// подготовка геометрии ОДНОГО пространства (`wallBodiesGeometry`, единый
// polyclip-union, неделимый): три такие задачи на дом вместо одной на 1,5 с.
// Порог с запасом к раннеру CI; мутант «расчёт целиком» даёт ~1,5 с и падает.
console.log(` диагностика: долгих задач ${settled.tasks.length ? settled.tasks.join(', ') : 'нет'};`
+ ` значения через ${Math.round(settled.waitedMs)} мс`);
check('noLongTaskWhileComputing', !settled.observed || settled.longest < 450);
// AC5: появление панели анимируется — кадры анимации реально существуют.
const motion = await page.evaluate(async () => {
const f = window.__firstPaint;
const toggle = f.root().querySelector('.summary-control')?.querySelector('button:last-child');
toggle?.click(); // скрыть
await f.card.updateComplete;
const exiting = (f.overlay()?.getAnimations?.() || []).map((a) => a.effect?.getTiming?.().duration);
await new Promise((resolve) => setTimeout(resolve, 320));
toggle?.click(); // показать снова
await f.card.updateComplete;
const entering = (f.overlay()?.getAnimations?.() || []).map((a) => a.effect?.getTiming?.().duration);
return { exiting, entering };
});
check('exitAnimated', motion.exiting.includes(190));
check('enterAnimated', motion.entering.includes(190));
await browser.close();
}
// --- (б) reduced motion: прямоугольник есть, моторики нет ---
{
const { page, browser } = await launch({ width: 1280, height: 900 });
await openLargePlan(page, { reducedMotion: true });
const calm = await page.evaluate(async () => {
const f = window.__firstPaint;
const toggle = f.root().querySelector('.summary-control')?.querySelector('button:last-child');
toggle?.click();
await f.card.updateComplete;
await f.frame();
const skeleton = f.overlay()?.querySelector('.summary-value-pending i');
const style = skeleton ? getComputedStyle(skeleton) : null;
const overlayAnimations = (f.overlay()?.getAnimations?.() || []).length;
return {
hasSkeleton: !!skeleton,
animationName: style?.animationName || '',
opacity: Number(style?.opacity || '0'),
overlayAnimations,
};
});
// AC6: прямоугольник остаётся видимым, пульсация выключена.
check('skeletonStillDrawn', calm.hasSkeleton && calm.opacity > 0.05);
check('skeletonDoesNotPulse', calm.animationName === 'none');
// AC5 (вторая половина): при reduced motion панель не анимируется вовсе.
check('panelNotAnimatedWhenReduced', calm.overlayAnimations === 0);
await browser.close();
}
finish();