453 lines
16 KiB
TypeScript
453 lines
16 KiB
TypeScript
#!/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)
|
|
})
|