mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
170 lines
7.0 KiB
JavaScript
170 lines
7.0 KiB
JavaScript
// #73 stable-release pixel gate. Unlike a loop of Playwright screenshots,
|
|
// CDP screencast observes compositor-presented frames and can therefore catch
|
|
// the single black/empty frame that motivated the visual-continuity contract.
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { launch, check, finish } from './serve.mjs';
|
|
|
|
const { page, browser } = await launch(
|
|
{ width: 820, height: 760 }, 1, [], { hasTouch: true },
|
|
);
|
|
const session = await page.context().newCDPSession(page);
|
|
const frames = [];
|
|
let stopped = false;
|
|
|
|
session.on('Page.screencastFrame', (event) => {
|
|
if (frames.length < 180) frames.push(event.data);
|
|
void session.send('Page.screencastFrameAck', { sessionId: event.sessionId });
|
|
});
|
|
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
try {
|
|
await page.waitForFunction(() => {
|
|
const card = window.__card;
|
|
const root = card?.shadowRoot || card?.renderRoot;
|
|
const host = root?.querySelector('ha-card');
|
|
return host?.dataset.continuityState === 'steady' && !!host?.dataset.frameFingerprint;
|
|
});
|
|
const stage = await page.locator('houseplan-card').evaluate((card) => {
|
|
const node = (card.shadowRoot || card.renderRoot).querySelector('.stage');
|
|
const rect = node.getBoundingClientRect();
|
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
});
|
|
// The reference is an explicitly settled DOM screenshot, never the first
|
|
// compositor frame emitted by the screencast itself. A degraded first frame
|
|
// must fail against this baseline rather than widening its own tolerance.
|
|
const baselinePng = await page.screenshot({ clip: stage });
|
|
|
|
await session.send('Page.startScreencast', {
|
|
format: 'png', everyNthFrame: 1, maxWidth: 820, maxHeight: 760,
|
|
});
|
|
await wait(120);
|
|
|
|
// #579: exercise a real multi-frame pinch while CDP records compositor-
|
|
// presented frames. DOM screenshots cannot witness the transient white or
|
|
// transparent WebView frame reported by users.
|
|
const pinchFrameStart = frames.length;
|
|
await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const stage = (card.shadowRoot || card.renderRoot).querySelector('.stage');
|
|
const rect = stage.getBoundingClientRect();
|
|
const centerX = rect.x + rect.width / 2;
|
|
const centerY = rect.y + rect.height / 2;
|
|
stage.setPointerCapture = () => {};
|
|
stage.releasePointerCapture = () => {};
|
|
const send = (type, pointerId, x, buttons = 1) => stage.dispatchEvent(new PointerEvent(type, {
|
|
bubbles: true,
|
|
composed: true,
|
|
pointerId,
|
|
pointerType: 'touch',
|
|
isPrimary: pointerId === 731,
|
|
button: type === 'pointerdown' ? 0 : -1,
|
|
buttons,
|
|
clientX: x,
|
|
clientY: centerY,
|
|
}));
|
|
send('pointerdown', 731, centerX - 72);
|
|
send('pointerdown', 732, centerX + 72);
|
|
const distances = [80, 91, 70, 98, 66, 104, 73, 96, 68, 101, 75, 94];
|
|
for (const distance of distances) {
|
|
send('pointermove', 731, centerX - distance);
|
|
send('pointermove', 732, centerX + distance);
|
|
await new Promise(requestAnimationFrame);
|
|
}
|
|
send('pointerup', 731, centerX - distances.at(-1), 0);
|
|
send('pointerup', 732, centerX + distances.at(-1), 0);
|
|
await card.updateComplete;
|
|
await new Promise(requestAnimationFrame);
|
|
});
|
|
await wait(180);
|
|
const pinchPresentedFrames = frames.length - pinchFrameStart;
|
|
|
|
await page.evaluate(() => window.__card._pageVisibility({
|
|
kind: 'visible', token: 73, at: Date.now(), hiddenFor: 20_000, long: true,
|
|
}));
|
|
// A harmless real paint gives the compositor enough damage to emit the
|
|
// candidate sequence even when the fresh server response equals the stale
|
|
// frame pixel-for-pixel.
|
|
await page.mouse.move(stage.x + stage.width * 0.42, stage.y + stage.height * 0.52);
|
|
await wait(850);
|
|
await page.mouse.move(2, 2);
|
|
await wait(180);
|
|
await session.send('Page.stopScreencast');
|
|
stopped = true;
|
|
|
|
const allMetrics = await page.evaluate(async ({ encoded, crop }) => {
|
|
const decode = (data) => new Promise((resolve, reject) => {
|
|
const image = new Image();
|
|
image.onload = () => resolve(image);
|
|
image.onerror = reject;
|
|
image.src = `data:image/png;base64,${data}`;
|
|
});
|
|
const out = [];
|
|
for (const data of encoded) {
|
|
const image = await decode(data);
|
|
const scaleX = image.width / innerWidth;
|
|
const scaleY = image.height / innerHeight;
|
|
const x = Math.max(0, Math.floor(crop.x * scaleX));
|
|
const y = Math.max(0, Math.floor(crop.y * scaleY));
|
|
const width = Math.max(1, Math.min(image.width - x, Math.floor(crop.width * scaleX)));
|
|
const height = Math.max(1, Math.min(image.height - y, Math.floor(crop.height * scaleY)));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(image, x, y, width, height, 0, 0, width, height);
|
|
const pixels = context.getImageData(0, 0, width, height).data;
|
|
let count = 0;
|
|
let sum = 0;
|
|
let sum2 = 0;
|
|
let dark = 0;
|
|
for (let row = 0; row < height; row += 4) {
|
|
for (let column = 0; column < width; column += 4) {
|
|
const offset = (row * width + column) * 4;
|
|
const luma = pixels[offset] * 0.2126
|
|
+ pixels[offset + 1] * 0.7152 + pixels[offset + 2] * 0.0722;
|
|
count++;
|
|
sum += luma;
|
|
sum2 += luma * luma;
|
|
if (luma < 18) dark++;
|
|
}
|
|
}
|
|
const mean = sum / Math.max(1, count);
|
|
out.push({
|
|
mean,
|
|
variance: sum2 / Math.max(1, count) - mean * mean,
|
|
darkRatio: dark / Math.max(1, count),
|
|
});
|
|
}
|
|
return out;
|
|
}, { encoded: [baselinePng.toString('base64'), ...frames], crop: stage });
|
|
|
|
const baseline = allMetrics[0] || { mean: 0, variance: 0, darkRatio: 1 };
|
|
const metrics = allMetrics.slice(1);
|
|
const forbidden = metrics.filter((frame) => (
|
|
frame.variance < Math.max(12, baseline.variance * 0.08)
|
|
|| frame.mean < Math.max(4, baseline.mean * 0.35)
|
|
|| (baseline.darkRatio < 0.85 && frame.darkRatio > 0.96)
|
|
));
|
|
|
|
mkdirSync('artifacts/continuity-screencast', { recursive: true });
|
|
frames.forEach((data, index) => writeFileSync(
|
|
`artifacts/continuity-screencast/frame-${String(index).padStart(3, '0')}.png`,
|
|
Buffer.from(data, 'base64'),
|
|
));
|
|
writeFileSync('artifacts/continuity-screencast/metrics.json', JSON.stringify({
|
|
stage, frames: metrics, forbidden: forbidden.length, pinchPresentedFrames,
|
|
}, null, 2));
|
|
|
|
const result = {
|
|
capturedPresentedFrames: frames.length >= 2,
|
|
capturedPinchPresentedFrames: pinchPresentedFrames >= 4,
|
|
baselineContainsPlanDetail: baseline.variance >= 12 && baseline.mean >= 4,
|
|
noEmptyOrBlackPresentedFrame: forbidden.length === 0,
|
|
};
|
|
for (const [name, value] of Object.entries(result)) check(name, value);
|
|
await finish(browser, result);
|
|
} finally {
|
|
if (!stopped) await session.send('Page.stopScreencast').catch(() => undefined);
|
|
await browser.close().catch(() => undefined);
|
|
}
|