mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
458 lines
19 KiB
JavaScript
458 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
|
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { launch } from './serve.mjs';
|
|
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
|
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
|
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
|
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
|
|
|
|
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
|
|
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
|
|
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
|
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
|
const profile = valueArg('profile') ?? 'large-house-v1';
|
|
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
|
|
throw new Error(`unknown large-house profile: ${profile}`);
|
|
const isometric = profile === 'large-house-isometric-v1';
|
|
const planSnap = profile === 'large-house-plan-snap-v1';
|
|
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
|
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
|
const fixture = makeLargeHouseFixture();
|
|
if (planSnap) {
|
|
for (const [floor, space] of fixture.config.spaces.entries()) {
|
|
space.room_drafts = [0, 1].map((draft) => {
|
|
const y = 0.985 + draft * 0.025;
|
|
return {
|
|
id: `perf-draft-${floor}-${draft}`,
|
|
points: [[0.10, y], [0.38, y], [0.46, y + 0.035]],
|
|
segments: [{ cm: 15 }, { cm: 20 }],
|
|
};
|
|
});
|
|
}
|
|
fixture.counts = { ...fixture.counts, drafts: 6, pointerMoves: 120 };
|
|
}
|
|
const viewport = { width: 1440, height: 1000 };
|
|
|
|
const { page, browser } = await launch(
|
|
viewport,
|
|
1,
|
|
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
|
|
{},
|
|
resolve(targetRoot, 'demo/srv'),
|
|
);
|
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
await page.addStyleTag({
|
|
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
|
|
});
|
|
await page.addScriptTag({
|
|
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
|
});
|
|
const chromium = await browser.version();
|
|
let buildFingerprint;
|
|
try {
|
|
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
|
|
} catch (error) {
|
|
await browser.close();
|
|
throw error;
|
|
}
|
|
|
|
const rows = [];
|
|
try {
|
|
for (let iteration = 0; iteration < warmups + samples; iteration++) {
|
|
const measuredSample = iteration - warmups;
|
|
const row = await page.evaluate(async ({
|
|
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
|
}) => {
|
|
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
const until = async (predicate, timeout = 10000) => {
|
|
const started = performance.now();
|
|
while (!predicate()) {
|
|
if (performance.now() - started > timeout) throw new Error('large-house benchmark timed out');
|
|
await new Promise((done) => setTimeout(done, 10));
|
|
}
|
|
};
|
|
const startLongTaskWindow = () => {
|
|
const entries = [];
|
|
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
|
|
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
|
|
}
|
|
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
|
observer.observe({ type: 'longtask', buffered: false });
|
|
return {
|
|
stop: async () => {
|
|
await new Promise((done) => setTimeout(done, 0));
|
|
entries.push(...observer.takeRecords());
|
|
observer.disconnect();
|
|
const durations = entries.map((entry) => entry.duration);
|
|
return {
|
|
supported: true,
|
|
count: durations.length,
|
|
maxMs: Number((durations.length ? Math.max(...durations) : 0).toFixed(2)),
|
|
totalMs: Number(durations.reduce((sum, value) => sum + value, 0).toFixed(2)),
|
|
};
|
|
},
|
|
};
|
|
};
|
|
const duration = async (action) => {
|
|
const longTasks = startLongTaskWindow();
|
|
const started = performance.now();
|
|
await action();
|
|
await frame();
|
|
return {
|
|
ms: Number((performance.now() - started).toFixed(2)),
|
|
longTasks: await longTasks.stop(),
|
|
};
|
|
};
|
|
const forceGc = async () => {
|
|
if (typeof globalThis.gc !== 'function') return false;
|
|
globalThis.gc();
|
|
await frame();
|
|
globalThis.gc();
|
|
await frame();
|
|
return true;
|
|
};
|
|
const cacheSnapshot = (card) => ({
|
|
cleanFloor: card._cleanFloorCache?.size ?? 0,
|
|
glowClip: card._glowClipCache?.size ?? 0,
|
|
wallUnion: card._wallUnionCache ? 1 : 0,
|
|
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
|
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
|
isoGeometry: card._isoGeometryCache?.size ?? 0,
|
|
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
|
|
});
|
|
|
|
window.__card?.remove?.();
|
|
localStorage.clear();
|
|
if (isometric) {
|
|
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
|
|
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
|
|
fixture.config.spaces.map((space) => [space.id, 'iso']),
|
|
)));
|
|
history.replaceState(null, '', '?hp-labs=iso');
|
|
} else history.replaceState(null, '', location.pathname);
|
|
const host = document.getElementById('host');
|
|
const card = document.createElement('houseplan-card');
|
|
card.setConfig({
|
|
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
|
|
});
|
|
let wsCalls = 0;
|
|
const connection = {
|
|
subscribeEvents: async () => () => undefined,
|
|
subscribeMessage: async () => () => undefined,
|
|
};
|
|
const hassFor = (states) => ({
|
|
language: 'en', locale: { language: 'en' },
|
|
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
|
|
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
|
|
floors: {
|
|
one: { floor_id: 'one', name: 'One', level: 0 },
|
|
two: { floor_id: 'two', name: 'Two', level: 1 },
|
|
three: { floor_id: 'three', name: 'Three', level: 2 },
|
|
},
|
|
callWS: async (message) => {
|
|
wsCalls++;
|
|
if (message.type === 'houseplan/config/get')
|
|
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
|
|
if (message.type === 'houseplan/layout/get')
|
|
return { layout: structuredClone(fixture.layout), rev: 1 };
|
|
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
|
|
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
|
|
if (message.type === 'config_entries/get')
|
|
return [{ entry_id: 'perf_entry', domain: 'houseplan_perf', title: 'Synthetic performance fixture' }];
|
|
if (message.type === 'manifest/list') return [{ domain: 'houseplan_perf', name: 'House Plan Performance' }];
|
|
return { ok: true };
|
|
},
|
|
callService: async () => undefined,
|
|
connection,
|
|
localize: () => null,
|
|
formatEntityState: (state) => state.state,
|
|
config: { unit_system: { length: 'km' } },
|
|
});
|
|
|
|
const loadLongTasks = startLongTaskWindow();
|
|
const loadStarted = performance.now();
|
|
host.replaceChildren(card);
|
|
card.hass = hassFor(fixture.states);
|
|
window.__hpAssertCardContract(card, cardContract);
|
|
if (requiresIsometric && (typeof card._setProjection !== 'function'
|
|
|| !(card._isoGeometryCache instanceof Map))) {
|
|
throw new Error('large-house-isometric-v1 candidate has no renderer contract');
|
|
}
|
|
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
|
|
await card.updateComplete;
|
|
await frame();
|
|
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
|
|
await until(() => card._booting === false);
|
|
await frame();
|
|
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
|
const loadLongTaskResult = await loadLongTasks.stop();
|
|
const viewToggle = isometric ? await duration(async () => {
|
|
if (typeof card._setProjection === 'function') {
|
|
card._setProjection('flat');
|
|
await card.updateComplete;
|
|
card._setProjection('iso');
|
|
await card.updateComplete;
|
|
} else {
|
|
// Comparison SHAs before #89 intentionally ignore the Labs operation.
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
}
|
|
}) : null;
|
|
const spaceSwitch = await duration(async () => {
|
|
card._pickSpace('perf-floor-2');
|
|
await card.updateComplete;
|
|
});
|
|
|
|
const firstEntity = Object.keys(fixture.states)[0];
|
|
const nextStates = {
|
|
...fixture.states,
|
|
[firstEntity]: { ...fixture.states[firstEntity], state: fixture.states[firstEntity].state === 'on' ? 'off' : 'on' },
|
|
};
|
|
const stateUpdate = await duration(async () => {
|
|
card.hass = hassFor(nextStates);
|
|
await card.updateComplete;
|
|
});
|
|
|
|
let planSnapDiagnostics = null;
|
|
const planSnapPointer = planSnap ? await duration(async () => {
|
|
card._setMode('plan');
|
|
card._tool = 'draw';
|
|
card._path = [];
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
await frame();
|
|
const stage = card.renderRoot.querySelector('.stage');
|
|
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
|
|
if (requiresPlanSnap && !overlay) throw new Error('plan-snap candidate has no overlay');
|
|
const staticLines = overlay?.querySelectorAll('.plan-snap-line').length ?? 0;
|
|
const staticNodes = overlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0;
|
|
const cacheValue = card._planSnapGeometryCache?.value ?? null;
|
|
const configBefore = JSON.stringify(card._serverCfg);
|
|
const callsBefore = wsCalls;
|
|
const view = card._viewOr(card._baseVb());
|
|
const rect = stage.getBoundingClientRect();
|
|
const fromPlan = (x, y) => ({
|
|
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
|
|
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
|
|
});
|
|
const firstEndpoint = overlay?.querySelector('.plan-snap-node[data-kind="endpoint"]');
|
|
const longLine = [...(overlay?.querySelectorAll('.plan-snap-line') || [])]
|
|
.map((line) => ({
|
|
line,
|
|
a: [+line.getAttribute('x1'), +line.getAttribute('y1')],
|
|
b: [+line.getAttribute('x2'), +line.getAttribute('y2')],
|
|
}))
|
|
.sort((a, b) => Math.hypot(b.b[0] - b.a[0], b.b[1] - b.a[1])
|
|
- Math.hypot(a.b[0] - a.a[0], a.b[1] - a.a[1]))[0];
|
|
const points = [
|
|
firstEndpoint
|
|
? [+firstEndpoint.getAttribute('cx'), +firstEndpoint.getAttribute('cy')]
|
|
: [40, 40],
|
|
longLine
|
|
? [(longLine.a[0] + longLine.b[0]) / 2, (longLine.a[1] + longLine.b[1]) / 2]
|
|
: [120, 40],
|
|
[10, 10],
|
|
];
|
|
const seenKinds = new Set();
|
|
for (let index = 0; index < 120; index++) {
|
|
const point = points[index % points.length];
|
|
stage.dispatchEvent(new PointerEvent('pointermove', {
|
|
...fromPlan(point[0], point[1]),
|
|
bubbles: true, composed: true, pointerId: 880, pointerType: 'mouse',
|
|
}));
|
|
await card.updateComplete;
|
|
const active = card.renderRoot.querySelector(
|
|
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
|
|
);
|
|
if (active) seenKinds.add(active.getAttribute('data-kind'));
|
|
if (requiresPlanSnap && card.renderRoot.querySelectorAll(
|
|
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
|
|
).length > 1) throw new Error('plan-snap rendered more than one active candidate');
|
|
}
|
|
const finalOverlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
|
|
planSnapDiagnostics = {
|
|
supported: requiresPlanSnap,
|
|
staticLines,
|
|
staticNodes,
|
|
activeKinds: [...seenKinds].sort(),
|
|
cacheStable: cacheValue != null && card._planSnapGeometryCache?.value === cacheValue,
|
|
domStable: (finalOverlay?.querySelectorAll('.plan-snap-line').length ?? 0) === staticLines
|
|
&& (finalOverlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0)
|
|
=== staticNodes,
|
|
configStable: JSON.stringify(card._serverCfg) === configBefore,
|
|
wsWrites: wsCalls - callsBefore,
|
|
};
|
|
if (requiresPlanSnap && (
|
|
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|
|
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|
|
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|
|
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
|
|
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
|
|
card._setMode('view');
|
|
await card.updateComplete;
|
|
}) : null;
|
|
|
|
const resizePreview = await duration(async () => {
|
|
card._setMode('plan');
|
|
card._tool = 'resize';
|
|
await card.updateComplete;
|
|
const room = card._rszRooms()[0];
|
|
const pointerId = 777;
|
|
const quietEvent = {
|
|
pointerId,
|
|
stopPropagation: () => undefined,
|
|
preventDefault: () => undefined,
|
|
target: null,
|
|
};
|
|
card._rszEdgeDown(quietEvent, room.id, 1);
|
|
const plan = card._rszDrag?.plan;
|
|
if (!plan) throw new Error('large-house resize plan was not created');
|
|
const target = [
|
|
plan.a[0] + plan.n[0] * card._gridPitch,
|
|
plan.a[1] + plan.n[1] * card._gridPitch,
|
|
];
|
|
const stage = card.renderRoot.querySelector('.stage');
|
|
const rect = stage.getBoundingClientRect();
|
|
const view = card._viewOr(card._baseVb());
|
|
card._rszMove({
|
|
...quietEvent,
|
|
clientX: rect.left + ((target[0] - view.x) / view.w) * rect.width,
|
|
clientY: rect.top + ((target[1] - view.y) / view.h) * rect.height,
|
|
});
|
|
await card.updateComplete;
|
|
card._rszCancelDrag();
|
|
card._setMode('view');
|
|
await card.updateComplete;
|
|
});
|
|
|
|
const stage = card.renderRoot.querySelector('.stage');
|
|
const rect = stage.getBoundingClientRect();
|
|
const panZoom = await duration(async () => {
|
|
stage.dispatchEvent(new WheelEvent('wheel', {
|
|
deltaY: -120, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
|
bubbles: true, cancelable: true,
|
|
}));
|
|
await card.updateComplete;
|
|
});
|
|
|
|
const settingsDialog = await duration(async () => {
|
|
card._openSettingsDialog();
|
|
await card.updateComplete;
|
|
});
|
|
card._settingsDialog = null;
|
|
await card.updateComplete;
|
|
|
|
const switchCycle = await duration(async () => {
|
|
for (let index = 0; index < 12; index++) {
|
|
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
|
|
await card.updateComplete;
|
|
// A user cannot produce twelve tab clicks in one JavaScript task.
|
|
// Yield between interactions so Long Task entries describe one
|
|
// switch, while switchCycleMs still measures the complete cycle.
|
|
await new Promise((done) => setTimeout(done, 0));
|
|
}
|
|
});
|
|
|
|
await forceGc();
|
|
const cacheBefore = cacheSnapshot(card);
|
|
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
|
|
for (let round = 0; round < 4; round++) {
|
|
for (let index = 0; index < 12; index++) {
|
|
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
|
|
await card.updateComplete;
|
|
await new Promise((done) => setTimeout(done, 0));
|
|
}
|
|
await forceGc();
|
|
}
|
|
const cacheEntries = cacheSnapshot(card);
|
|
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
|
|
const cacheGrowth = Object.fromEntries(
|
|
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
|
|
);
|
|
|
|
const result = {
|
|
sample,
|
|
modelReadyMs,
|
|
firstStableRenderMs,
|
|
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
|
...(planSnapPointer ? {
|
|
planSnapPointerMs: planSnapPointer.ms,
|
|
planSnapDiagnostics,
|
|
} : {}),
|
|
spaceSwitchMs: spaceSwitch.ms,
|
|
stateUpdateMs: stateUpdate.ms,
|
|
resizePreviewMs: resizePreview.ms,
|
|
panZoomMs: panZoom.ms,
|
|
settingsDialogMs: settingsDialog.ms,
|
|
switchCycleMs: switchCycle.ms,
|
|
longTasks: {
|
|
load: loadLongTaskResult,
|
|
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
|
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
|
spaceSwitch: spaceSwitch.longTasks,
|
|
stateUpdate: stateUpdate.longTasks,
|
|
resizePreview: resizePreview.longTasks,
|
|
panZoom: panZoom.longTasks,
|
|
settingsDialog: settingsDialog.longTasks,
|
|
switchCycle: switchCycle.longTasks,
|
|
},
|
|
cacheEntries,
|
|
cacheGrowth,
|
|
heapGrowthBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
|
|
preciseGc: typeof globalThis.gc === 'function',
|
|
renderedDevices: card._devices?.length ?? 0,
|
|
};
|
|
card.remove();
|
|
await frame();
|
|
return result;
|
|
}, {
|
|
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
|
isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
|
});
|
|
if (measuredSample >= 0) rows.push(row);
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
const metricNames = [
|
|
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
|
|
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
|
|
];
|
|
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
|
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
|
|
const report = {
|
|
schema: 2,
|
|
profile,
|
|
generatedAt: new Date().toISOString(),
|
|
buildFingerprint,
|
|
runtime: {
|
|
node: process.version,
|
|
chromium,
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
viewport,
|
|
deviceScaleFactor: 1,
|
|
reducedMotion: true,
|
|
},
|
|
fixture: LARGE_HOUSE_COUNTS,
|
|
samples,
|
|
warmups,
|
|
summary: summarizeTimings(rows, metricNames),
|
|
longTasks: summarizeLongTasks(rows),
|
|
rows,
|
|
note: `Compare with a base-SHA report captured by the same runner and evaluate the ${profile} budget.`,
|
|
};
|
|
|
|
const text = `${JSON.stringify(report, null, 2)}\n`;
|
|
if (output) {
|
|
mkdirSync(dirname(output), { recursive: true });
|
|
writeFileSync(output, text, 'utf8');
|
|
console.log(output);
|
|
} else {
|
|
process.stdout.write(text);
|
|
}
|