test(client): add headless browser verification script for Issue #542 HUD docking and hover tooltips
This commit is contained in:
parent
773e3be117
commit
7e46fab753
|
|
@ -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)
|
||||
})
|
||||
Loading…
Reference in New Issue