diablo2-web/scripts/verify-controlbar-browser.ts

297 lines
10 KiB
TypeScript

/**
* scripts/verify-controlbar-browser.ts
*
* Headless Chrome browser verification of ControlBar XP bar rendering & hover interactions:
* 1. Offscreen HTML Canvas Pixel Audit:
* - Real OffscreenCanvas in Google Chrome.
* - Renders 800CtrlPnl7 panel + ControlBarHud.
* - Extracts real pixel data via getImageData().
* - Verifies groove [256, 560, 120, 4] colors.
* - Verifies gold trim borders at y=559 and y=564 have ZERO pixel bleed.
* 2. Real DOM Mouse Event Projection across DPRs & Viewports:
* - Viewports: 800x600, 1920x1080.
* - DPRs: 1.0, 1.25, 1.5, 2.0.
* - Inspects window.__d2webHudInstance.controlBar.hoveredElement.
*/
import { spawn } from 'node:child_process'
import { createServer } from 'vite'
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
async function main(): Promise<void> {
console.log('=== Headless Chrome ControlBar Stress Test ===')
// 1. Start Vite dev server
const server = await createServer({
root: process.cwd(),
server: { port: 0, host: '127.0.0.1' },
})
await server.listen()
const address = server.httpServer?.address()
const port = typeof address === 'object' && address ? address.port : 5173
const baseUrl = `http://127.0.0.1:${port}`
console.log(`1. Vite server running at ${baseUrl}`)
// 2. Launch headless Chrome with CDP
const debugPort = 9248
const chromeProc = spawn('/usr/bin/google-chrome', [
'--headless=new',
`--remote-debugging-port=${debugPort}`,
'--no-sandbox',
'--disable-dev-shm-usage',
'--enable-webgl',
'--window-size=1920,1080',
`${baseUrl}/acts.html?act=1`,
])
try {
let wsUrl: string | null = null
for (let i = 0; i < 60; i++) {
await sleep(300)
try {
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[]
const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html'))
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch {}
}
if (!wsUrl) throw new Error('Failed to connect to Chrome CDP')
console.log('2. Connected to Chrome CDP:', wsUrl)
const ws = new WebSocket(wsUrl)
await new Promise<void>(resolve => { ws.onopen = () => resolve() })
let idCounter = 1
const sendCommand = <T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> =>
new Promise((resolve, reject) => {
const id = idCounter++
const handler = (event: MessageEvent) => {
const msg = JSON.parse(String(event.data))
if (msg.id === id) {
ws.removeEventListener('message', handler)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result as T)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
const evalJs = async <T = any>(expression: string): Promise<T> => {
const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (res.exceptionDetails) {
throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`)
}
return res.result.value
}
await sendCommand('Runtime.enable')
await sendCommand('Page.enable')
// Wait for HudManager to be ready
for (let i = 0; i < 100; i++) {
const ready = await evalJs<boolean>(`Boolean(window.__d2webHud?.ready && window.__d2webHudInstance)`)
if (ready) break
await sleep(300)
}
console.log('3. HudManager ready in Chrome!')
// Test 1: Real OffscreenCanvas rendering & pixel audit inside Chrome
console.log('\n--- Test 1: Chrome OffscreenCanvas Pixel Audit ---')
const pixelAuditResult = await evalJs<{
bleedTop559Count: number
bleedBot564Count: number
grooveBackgroundRgb: number[]
grooveFilledRgb: number[]
tickNotchRgb: number[]
tickPositions: number[]
}>(`(() => {
const hudInst = window.__d2webHudInstance;
const ctrlBar = hudInst.controlBar;
const ctrlImg = hudInst.images.get('ctrlPnl');
// Create base OffscreenCanvas with only background image
const baseCanvas = new OffscreenCanvas(800, 600);
const baseCtx = baseCanvas.getContext('2d');
baseCtx.drawImage(ctrlImg, 0, 496);
const baseImgData = baseCtx.getImageData(0, 0, 800, 600).data;
// Create test OffscreenCanvas and render ControlBar with 50% XP
const testCanvas = new OffscreenCanvas(800, 600);
const testCtx = testCanvas.getContext('2d');
const testState = {
level: 1,
xp: 1500,
currentLevelXp: 1000,
nextLevelXp: 2000,
stamina: 100,
maxStamina: 100,
isRunning: false,
unspentStatPoints: 0,
unspentSkillPoints: 0,
};
const assetBundle = {
ctrlPnlImg: hudInst.images.get('ctrlPnl') ?? null,
runBtnImg: hudInst.images.get('runbutton') ?? null,
menuBtnImg: hudInst.images.get('menubutton') ?? null,
miniPanelImg: hudInst.images.get('minipanel') ?? null,
miniPanelBtnsImg: hudInst.images.get('minipanelBtns') ?? null,
levelBtnImg: hudInst.images.get('levelBtn') ?? null,
levelSocketImg: hudInst.images.get('levelSocket') ?? null,
};
ctrlBar.draw(testCtx, testState, assetBundle, hudInst.font);
const testImgData = testCtx.getImageData(0, 0, 800, 600).data;
let bleedTop559Count = 0;
let bleedBot564Count = 0;
// Check row y=559 for x in [256..375]
for (let x = 256; x <= 375; x++) {
const idx = (559 * 800 + x) * 4;
if (
testImgData[idx] !== baseImgData[idx] ||
testImgData[idx + 1] !== baseImgData[idx + 1] ||
testImgData[idx + 2] !== baseImgData[idx + 2]
) {
bleedTop559Count++;
}
}
// Check row y=564 for x in [256..375]
for (let x = 256; x <= 375; x++) {
const idx = (564 * 800 + x) * 4;
if (
testImgData[idx] !== baseImgData[idx] ||
testImgData[idx + 1] !== baseImgData[idx + 1] ||
testImgData[idx + 2] !== baseImgData[idx + 2]
) {
bleedBot564Count++;
}
}
// Sample groove pixels
// Filled area (x=260, y=561)
const filledIdx = (561 * 800 + 260) * 4;
const grooveFilledRgb = [testImgData[filledIdx], testImgData[filledIdx + 1], testImgData[filledIdx + 2]];
// Unfilled area (x=350, y=561)
const unfilledIdx = (561 * 800 + 350) * 4;
const grooveBackgroundRgb = [testImgData[unfilledIdx], testImgData[unfilledIdx + 1], testImgData[unfilledIdx + 2]];
// Tick notch at x=268, y=561
const notchIdx = (561 * 800 + 268) * 4;
const tickNotchRgb = [testImgData[notchIdx], testImgData[notchIdx + 1], testImgData[notchIdx + 2]];
// Verify all 9 ticks
const tickPositions = [];
for (let i = 1; i < 10; i++) {
const tx = 256 + i * 12;
const tidx = (561 * 800 + tx) * 4;
tickPositions.push(testImgData[tidx]);
}
return {
bleedTop559Count,
bleedBot564Count,
grooveBackgroundRgb,
grooveFilledRgb,
tickNotchRgb,
tickPositions,
};
})()`)
console.log('Pixel audit result from Chrome:', JSON.stringify(pixelAuditResult, null, 2))
// Test 2: Hover Hit-Testing in real Chrome across DPRs & Resolutions
console.log('\n--- Test 2: Hover Hit-Testing in Chrome across DPRs & Resolutions ---')
const resolutions = [
{ w: 800, h: 600 },
{ w: 1920, h: 1080 },
]
const dprs = [1.0, 1.25, 1.5, 2.0]
for (const res of resolutions) {
for (const dpr of dprs) {
// Set emulation device metrics
await sendCommand('Emulation.setDeviceMetricsOverride', {
width: res.w,
height: res.h,
deviceScaleFactor: dpr,
mobile: false,
})
await sleep(100)
// Evaluate hover at key points
const hoverResult = await evalJs<{
centerHover: string | null
outsideHover: string | null
leftEdgeHover: string | null
rightEdgeHover: string | null
}>(`(() => {
const inst = window.__d2webHudInstance;
const rect = inst.hudCanvas.getBoundingClientRect();
// Calculate client coords for center
const scale = Math.min(rect.width / 800, rect.height / 600);
const offX = Math.max(0, (rect.width - 800 * scale) / 2);
const offY = Math.max(0, rect.height - 600 * scale);
const clientCenterX = 316 * scale + offX;
const clientCenterY = 562 * scale + offY;
const mappedCenter = inst.clientToLogical(clientCenterX, clientCenterY);
inst.controlBar.handleMouseMove(mappedCenter.x, mappedCenter.y);
const centerHover = inst.controlBar.hoveredElement;
// Test point outside (above groove)
const clientOutsideY = 550 * scale + offY;
const mappedOutside = inst.clientToLogical(clientCenterX, clientOutsideY);
inst.controlBar.handleMouseMove(mappedOutside.x, mappedOutside.y);
const outsideHover = inst.controlBar.hoveredElement;
// Left edge (x = 256.5 to avoid IEEE 754 epsilon boundary)
const clientLeftX = 256.5 * scale + offX;
const mappedLeft = inst.clientToLogical(clientLeftX, clientCenterY);
inst.controlBar.handleMouseMove(mappedLeft.x, mappedLeft.y);
const leftEdgeHover = inst.controlBar.hoveredElement;
// Right edge (x = 375.5)
const clientRightX = 375.5 * scale + offX;
const mappedRight = inst.clientToLogical(clientRightX, clientCenterY);
inst.controlBar.handleMouseMove(mappedRight.x, mappedRight.y);
const rightEdgeHover = inst.controlBar.hoveredElement;
return {
centerHover,
outsideHover,
leftEdgeHover,
rightEdgeHover,
};
})()`)
console.log(`Resolution ${res.w}x${res.h} @ DPR ${dpr}:`, hoverResult)
}
}
} finally {
chromeProc.kill('SIGKILL')
await server.close()
}
}
main().catch(err => {
console.error(err)
process.exit(1)
})