test(client): add headless browser verification script for Issue #542 HUD docking and hover tooltips

This commit is contained in:
troytt 2026-10-02 11:08:54 +00:00
parent 773e3be117
commit 7e46fab753
1 changed files with 452 additions and 0 deletions

View File

@ -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<void> {
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<CdpSession> {
const ws = new WebSocket(wsUrl)
await new Promise<void>((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<string, unknown> = {}): Promise<any> {
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<T = any>(expression: string): Promise<T> {
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<number> {
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<void> {
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<void> {
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<typeof spawn> | 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)
})