// #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); }