From 7e46fab753fbd1d6cfb3f390d047ad4ee4ffe304 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 11:08:54 +0000 Subject: [PATCH] test(client): add headless browser verification script for Issue #542 HUD docking and hover tooltips --- tools/verify-issue-542-browser.ts | 452 ++++++++++++++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 tools/verify-issue-542-browser.ts diff --git a/tools/verify-issue-542-browser.ts b/tools/verify-issue-542-browser.ts new file mode 100644 index 0000000..184447a --- /dev/null +++ b/tools/verify-issue-542-browser.ts @@ -0,0 +1,452 @@ +#!/usr/bin/env npx tsx +/** + * Headless Chrome Browser Verification for Issue #542: + * HUD docking input coordinates, interactive clicks, and hover tooltips + * across extended viewport profiles (1024x768, 1068x600, 1280x720). + */ + +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createServer, type ViteDevServer } from 'vite' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT_DIR = resolve(__dirname, '..') + +const CHROME_CANDIDATES = [ + join(process.env.HOME ?? '', '.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'), + '/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome', + '/usr/bin/google-chrome', +] + +function findChromeBinary(): string { + for (const cand of CHROME_CANDIDATES) { + if (cand && existsSync(cand)) return cand + } + throw new Error(`No headless Chrome binary found in candidates: ${CHROME_CANDIDATES.join(', ')}`) +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +class CdpSession { + private readonly ws: WebSocket + private msgId = 0 + private readonly pending = new Map< + number, + { readonly resolve: (val: any) => void; readonly reject: (err: Error) => void } + >() + readonly consoleErrors: string[] = [] + readonly pageExceptions: string[] = [] + + private constructor(ws: WebSocket) { + this.ws = ws + this.ws.onmessage = (ev) => { + const msg = JSON.parse(String(ev.data)) + if (typeof msg.id === 'number') { + const entry = this.pending.get(msg.id) + if (entry) { + this.pending.delete(msg.id) + if (msg.error) { + entry.reject(new Error(msg.error.message || JSON.stringify(msg.error))) + } else { + entry.resolve(msg.result) + } + } + return + } + if (msg.method === 'Runtime.consoleAPICalled') { + const type = msg.params?.type + if (type === 'error') { + const text = (msg.params?.args ?? []) + .map((a: any) => a.value ?? a.description ?? '') + .join(' ') + if (!text.includes('favicon.ico') && !text.includes('404')) { + this.consoleErrors.push(text) + } + } + } else if (msg.method === 'Runtime.exceptionThrown') { + const details = msg.params?.exceptionDetails + const desc = + details?.exception?.description ?? details?.text ?? JSON.stringify(details) + this.pageExceptions.push(String(desc)) + } + } + } + + static async connect(wsUrl: string): Promise { + const ws = new WebSocket(wsUrl) + await new Promise((resolve, reject) => { + ws.onopen = () => resolve() + ws.onerror = (err) => reject(new Error(`CDP WebSocket error: ${String(err)}`)) + }) + const cdp = new CdpSession(ws) + await cdp.send('Runtime.enable') + await cdp.send('Page.enable') + return cdp + } + + send(method: string, params: Record = {}): Promise { + const id = ++this.msgId + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + this.ws.send(JSON.stringify({ id, method, params })) + }) + } + + async evaluate(expression: string): Promise { + const res = await this.send('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }) + if (res?.exceptionDetails) { + const desc = + res.exceptionDetails.exception?.description ?? + res.exceptionDetails.text ?? + JSON.stringify(res.exceptionDetails) + throw new Error(`CDP evaluate exception: ${String(desc)}`) + } + return res?.result?.value as T + } + + async captureScreenshot(outPath: string): Promise { + const shot = await this.send('Page.captureScreenshot', { format: 'png' }) + const data = shot?.data + if (typeof data !== 'string' || data.length === 0) { + throw new Error('Failed to capture PNG screenshot via CDP') + } + const buf = Buffer.from(data, 'base64') + mkdirSync(dirname(outPath), { recursive: true }) + writeFileSync(outPath, buf) + return buf.byteLength + } + + async close(): Promise { + try { + this.ws.close() + } catch { + // ignore + } + } +} + +interface ProfileAuditSpec { + readonly id: '1024x768' | '1068x600' | '1280x720' + readonly width: number + readonly height: number + readonly barOffsetX: number + readonly barOffsetY: number + readonly panelOffsetY: number +} + +const EXTENDED_PROFILES: readonly ProfileAuditSpec[] = [ + { id: '1024x768', width: 1024, height: 768, barOffsetX: 112, barOffsetY: 168, panelOffsetY: 84 }, + { id: '1068x600', width: 1068, height: 600, barOffsetX: 134, barOffsetY: 0, panelOffsetY: 0 }, + { id: '1280x720', width: 1280, height: 720, barOffsetX: 240, barOffsetY: 120, panelOffsetY: 60 }, +] + +async function run(): Promise { + const chromeBin = findChromeBinary() + const outDir = resolve(ROOT_DIR, 'samples/screenshots-issue-542') + mkdirSync(outDir, { recursive: true }) + + const fixturePath = resolve(ROOT_DIR, 'tests/fixtures/netproto/02-d2gs-town-walk-chat.d2cap') + if (!existsSync(fixturePath)) { + throw new Error(`Missing fixture at ${fixturePath}`) + } + const d2capData = readFileSync(fixturePath, 'utf8') + + let viteServer: ViteDevServer | null = null + const userDataDir = mkdtempSync(join(tmpdir(), 'd2-verify-542-')) + let chromeProc: ReturnType | null = null + let cdp: CdpSession | null = null + + try { + viteServer = await createServer({ + root: ROOT_DIR, + server: { + host: '127.0.0.1', + port: 0, + strictPort: false, + watch: null, + }, + logLevel: 'error', + }) + await viteServer.listen() + const addr = viteServer.httpServer?.address() + const port = typeof addr === 'object' && addr ? addr.port : 0 + if (!port) throw new Error('Failed to start Vite server') + + const targetUrl = `http://127.0.0.1:${port}/play.html` + console.log(`[verify-issue-542] Vite server listening at ${targetUrl}`) + + chromeProc = spawn( + chromeBin, + [ + '--headless=new', + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu-sandbox', + '--use-angle=swiftshader', + '--use-gl=angle', + '--enable-unsafe-swiftshader', + '--ignore-gpu-blocklist', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + '--no-proxy-server', + '--password-store=basic', + '--disable-extensions', + '--no-first-run', + '--no-default-browser-check', + '--force-device-scale-factor=1', + '--window-size=1280,960', + `--user-data-dir=${userDataDir}`, + '--remote-debugging-port=0', + 'about:blank', + ], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ) + + let cdpPort: string | null = null + for (let i = 0; i < 100; i++) { + await sleep(150) + try { + const raw = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8') + const firstLine = raw.trim().split('\n')[0] + if (firstLine) { + cdpPort = firstLine + break + } + } catch { + // wait + } + } + if (!cdpPort) throw new Error('Chrome failed to start DevToolsActivePort') + + let pageTarget: { readonly url: string; readonly webSocketDebuggerUrl: string } | null = null + for (let i = 0; i < 120; i++) { + await sleep(150) + try { + const res = await fetch(`http://127.0.0.1:${cdpPort}/json`) + const list = (await res.json()) as any[] + const pages = list.filter((p) => p.type === 'page') + if (pages.length > 0 && pages[0]) { + pageTarget = pages[0] + break + } + } catch { + // wait + } + } + if (!pageTarget) throw new Error('Failed to attach to page target in headless Chrome') + + cdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl) + await cdp.send('Page.navigate', { url: targetUrl }) + + // Wait for play.html boot + let ready = false + for (let i = 0; i < 150; i++) { + const state = await cdp.evaluate<{ ready: boolean } | null>( + `window.__d2Play ? { ready: Boolean(window.__d2Play.ready) } : null`, + ) + if (state?.ready) { + ready = true + break + } + await sleep(200) + } + if (!ready) throw new Error('play.html failed to boot ready=true') + console.log('[verify-issue-542] play.html boot ready') + + // Enter in-game view via D2Cap replay + await cdp.evaluate(`(() => { + const app = window.__d2Play; + app.replayD2Cap(${JSON.stringify(d2capData)}); + })()`) + await sleep(500) + + for (const spec of EXTENDED_PROFILES) { + console.log(`\n─── Testing Extended Viewport Profile: ${spec.id} (${spec.width}x${spec.height}) ───`) + + // 1. Resize browser viewport via CDP Emulation + await cdp.send('Emulation.setDeviceMetricsOverride', { + width: spec.width, + height: spec.height, + deviceScaleFactor: 1, + mobile: false, + }) + + // 2. Set viewport profile in settingsStore and trigger stage resize + const layoutAudit = await cdp.evaluate<{ + viewportId: string + profileWidth: number + profileHeight: number + barOffsetX: number + barOffsetY: number + panelOffsetY: number + leftDockX: number + rightDockX: number + channelWidth: number + }>(`(() => { + const app = window.__d2Play; + app.settingsStore.update({ viewport: ${JSON.stringify(spec.id)} }); + app.renderFrame(performance.now()); + const hud = app.hudManager; + const docking = hud.getDockingLayout(); + return { + viewportId: hud.viewportProfile.id, + profileWidth: hud.viewportProfile.width, + profileHeight: hud.viewportProfile.height, + barOffsetX: docking.barOffsetX, + barOffsetY: docking.barOffsetY, + panelOffsetY: docking.panelOffsetY, + leftDockX: docking.leftDockX, + rightDockX: docking.rightDockX, + channelWidth: docking.channelWidthLogical, + }; + })()`) + + console.log(`[${spec.id}] Docking metrics:`, layoutAudit) + if ( + layoutAudit.viewportId !== spec.id || + layoutAudit.profileWidth !== spec.width || + layoutAudit.profileHeight !== spec.height || + layoutAudit.barOffsetX !== spec.barOffsetX || + layoutAudit.barOffsetY !== spec.barOffsetY || + layoutAudit.panelOffsetY !== spec.panelOffsetY || + layoutAudit.leftDockX !== 0 || + layoutAudit.rightDockX !== spec.width - 400 + ) { + throw new Error(`[${spec.id}] Docking layout metrics mismatch: ${JSON.stringify(layoutAudit)}`) + } + + // 3. Test interactive clicks on bottom control bar: Run/Walk toggle & mini-panel buttons + const barClickAudit = await cdp.evaluate<{ + runToggled: boolean + charOpened: boolean + invOpened: boolean + beltSlotHovered: boolean + }>(`(() => { + const app = window.__d2Play; + const hud = app.hudManager; + const barOffsetX = ${spec.barOffsetX}; + const barOffsetY = ${spec.barOffsetY}; + + // Run/Walk toggle at center (263 + barOffsetX, 582 + barOffsetY) + const prevRun = hud.isRunning; + hud.handleClick(263 + barOffsetX, 582 + barOffsetY, 0); + const runToggled = hud.isRunning !== prevRun; + + // Mini-panel Char button at (325 + barOffsetX, 535 + barOffsetY) + hud.leftPanel = 'none'; + hud.handleClick(325 + barOffsetX, 535 + barOffsetY, 0); + const charOpened = hud.leftPanel === 'char'; + + // Mini-panel Inv button at (352 + barOffsetX, 535 + barOffsetY) + hud.rightPanel = 'none'; + hud.handleClick(352 + barOffsetX, 535 + barOffsetY, 0); + const invOpened = hud.rightPanel === 'inv'; + + // Hover over Belt slot (0, 0) at (435 + barOffsetX, 575 + barOffsetY) + hud.handleMouseMove(435 + barOffsetX, 575 + barOffsetY); + const beltSlotHovered = hud.belt.hoveredSlot !== null && hud.belt.hoveredSlot.col === 0 && hud.belt.hoveredSlot.row === 0; + + app.renderFrame(performance.now()); + return { runToggled, charOpened, invOpened, beltSlotHovered }; + })()`) + + console.log(`[${spec.id}] Bar click & hover audit:`, barClickAudit) + if (!barClickAudit.runToggled || !barClickAudit.charOpened || !barClickAudit.invOpened || !barClickAudit.beltSlotHovered) { + throw new Error(`[${spec.id}] Control bar interactive click/hover failed: ${JSON.stringify(barClickAudit)}`) + } + + // Capture screenshot with both left dock (char) and right dock (inv) open + const dockShotPath = join(outDir, `play-${spec.id}-hud-docking.png`) + await cdp.captureScreenshot(dockShotPath) + console.log(`[${spec.id}] Saved screenshot: ${dockShotPath}`) + + // 4. Test inventory hover item tooltip at docked right coordinates + const invHoverAudit = await cdp.evaluate<{ + hoveredItemFound: boolean + hoveredX: number + hoveredY: number + tooltipBoxX: number + tooltipBoxW: number + tooltipClampedInsideViewport: boolean + }>(`(() => { + const app = window.__d2Play; + const hud = app.hudManager; + const deltaRight = ${spec.width} - 400 - 400; // rightDockX - 400 + const panelOffsetY = ${spec.panelOffsetY}; + + hud.rightPanel = 'inv'; + // Add a test ring at (col 9, row 0) to test outer boundary clamping + hud.inventory.gridItems = [{ + item: { + id: 'test-outer-ring', + code: 'rin', + name: 'Nagelring', + nameZh: '拿各的戒指', + baseNameZh: '戒指', + quality: 'unique', + invWidth: 1, + invHeight: 1, + stats: [ + { text: '+75 to Attack Rating', color: 'blue' }, + { text: 'Magic Damage Reduced by 3', color: 'blue' }, + { text: 'Attacker Takes Damage of 3', color: 'blue' }, + { text: '30% Better Chance of Getting Magic Items', color: 'blue' }, + ], + allowedSlots: ['ring1', 'ring2'], + }, + col: 9, + row: 0, + }]; + + // INV_GRID_ORIGIN is { x: 418, y: 316, cols: 10, rows: 4, cellPx: 29 } + const hx = 418 + 9 * 29 + 14.5 + deltaRight; + const hy = 316 + 14.5 + panelOffsetY; + hud.handleMouseMove(hx, hy); + app.renderFrame(performance.now()); + + const hover = hud.inventory.hoveredItem; + const rect = hud.worldPanels.lastTooltipRect; + return { + hoveredItemFound: Boolean(hover && hover.item.id === 'test-outer-ring'), + hoveredX: hover ? hover.x : 0, + hoveredY: hover ? hover.y : 0, + tooltipBoxX: rect ? rect.bx : 0, + tooltipBoxW: rect ? rect.boxW : 0, + tooltipClampedInsideViewport: Boolean(rect && (rect.bx + rect.boxW <= ${spec.width} - 8)), + }; + })()`) + + console.log(`[${spec.id}] Inventory hover & tooltip audit:`, invHoverAudit) + if (!invHoverAudit.hoveredItemFound || !invHoverAudit.tooltipClampedInsideViewport) { + throw new Error(`[${spec.id}] Inventory item hover & tooltip clamping failed: ${JSON.stringify(invHoverAudit)}`) + } + + // Capture screenshot with inventory item hover tooltip visible + const tipShotPath = join(outDir, `play-${spec.id}-inventory-hover-tooltip.png`) + await cdp.captureScreenshot(tipShotPath) + console.log(`[${spec.id}] Saved screenshot: ${tipShotPath}`) + } + + console.log('\n=== Headless Browser Verification on play.html across all 3 extended viewports Passed (0 errors) ===') + } finally { + if (cdp) await cdp.close() + if (chromeProc) chromeProc.kill('SIGKILL') + if (viteServer) await viteServer.close() + rmSync(userDataDir, { recursive: true, force: true }) + } +} + +run().catch((err) => { + console.error('[verify-issue-542] Fatal error:', err) + process.exit(1) +})