/** * Automated Headless Browser Verification & Visual Audit for Issue #503: * Full Chinese (`CHI`) and English (`ENG`) Bilingual Versions (`index.html`, `acts-zh.html`, `acts-en.html`, `acts.html`). * * Uses Vite dev server + `/usr/bin/google-chrome` over native WebSocket CDP. */ import { spawn } from 'node:child_process' import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { createServer } from 'vite' const ARTIFACT_DIR = process.argv[2] || '/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/worker_503_1' const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) interface CdpSession { evalJs: (expression: string) => Promise saveScreenshot: (filename: string) => Promise waitForGameReady: () => Promise } async function withChromePage( url: string, matchSubstr: string, debugPort: number, fn: (session: CdpSession) => Promise, ): Promise { const chromeProc = spawn('/usr/bin/google-chrome', [ '--headless=new', `--remote-debugging-port=${debugPort}`, '--no-sandbox', '--disable-dev-shm-usage', '--enable-webgl', '--ignore-gpu-blocklist', '--use-gl=angle', '--use-angle=swiftshader', '--window-size=1280,840', url, ]) 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(matchSubstr)) if (page?.webSocketDebuggerUrl) { wsUrl = page.webSocketDebuggerUrl break } } catch { // Wait for Chrome CDP } } if (!wsUrl) { throw new Error(`Failed to connect to Chrome CDP for ${url}`) } const ws = new WebSocket(wsUrl) await new Promise(resolve => { ws.onopen = () => resolve() }) let idCounter = 1 const sendCommand = (method: string, params: Record = {}): Promise => 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 R) } } ws.addEventListener('message', handler) ws.send(JSON.stringify({ id, method, params })) }) const evalJs = async (expression: string): Promise => { const res = await sendCommand<{ result: { value: R }; 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 } const saveScreenshot = async (filename: string): Promise => { const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' }) writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64')) console.log(` Saved screenshot: ${filename}`) } const waitForGameReady = async (): Promise => { for (let i = 0; i < 120; i++) { const ready = await evalJs( `Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHudInstance && window.__d2webHud?.assetsLoaded?.ctrlPnl)`, ) if (ready) { await sleep(500) return } await sleep(300) } throw new Error(`Timed out waiting for game ready at ${url}`) } await sendCommand('Runtime.enable') await sendCommand('Page.enable') const result = await fn({ evalJs, saveScreenshot, waitForGameReady }) ws.close() return result } finally { chromeProc.kill('SIGKILL') await sleep(200) } } async function main(): Promise { mkdirSync(ARTIFACT_DIR, { recursive: true }) 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 dev server listening at ${baseUrl}`) try { // ── Phase A: Verify index.html bilingual entry portal ── await withChromePage(`${baseUrl}/index.html`, 'index.html', 9241, async ({ evalJs, saveScreenshot }) => { await sleep(400) const indexCheck = await evalJs<{ zhHref: string | null enHref: string | null title: string }>(`({ zhHref: document.getElementById('enter-zh')?.getAttribute('href') ?? null, enHref: document.getElementById('enter-en')?.getAttribute('href') ?? null, title: document.title, })`) console.log('2. Verified index.html entry portal:', JSON.stringify(indexCheck)) if (indexCheck.zhHref !== './acts-zh.html' || indexCheck.enHref !== './acts-en.html') { throw new Error(`Unexpected index.html links: ${JSON.stringify(indexCheck)}`) } await saveScreenshot('index-bilingual.png') }) // ── Phase B: Verify acts-zh.html (Chinese CHI edition) ── await withChromePage(`${baseUrl}/acts-zh.html?act=1`, 'acts-zh.html', 9242, async ({ evalJs, saveScreenshot, waitForGameReady }) => { await waitForGameReady() const zhCheck = await evalJs<{ lang: string title: string langSwitchHref: string | null langSwitchText: string hudText: string actOpt1: string heroSor: string sceneSelectedText: string charSheetDraws: string[] }>(`(() => { const inst = window.__d2webHudInstance; const draws = []; const origFill = CanvasRenderingContext2D.prototype.fillText; CanvasRenderingContext2D.prototype.fillText = function(text, ...rest) { draws.push(String(text)); return origFill.call(this, text, ...rest); }; try { if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char'); if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv'); inst.inventory.handleMouseMove(561, 93); inst.render(performance.now(), false); } finally { CanvasRenderingContext2D.prototype.fillText = origFill; } const sceneSel = document.getElementById('scene'); return { lang: inst.lang, title: document.title, langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null, langSwitchText: document.getElementById('lang-switch')?.textContent?.trim() ?? '', hudText: document.getElementById('hud')?.textContent ?? '', actOpt1: document.querySelector('#act option[value="1"]')?.textContent ?? '', heroSor: document.querySelector('#hero option[value="so"]')?.textContent ?? '', sceneSelectedText: sceneSel?.selectedOptions?.[0]?.textContent ?? '', charSheetDraws: draws, }; })()`) console.log('3. Verified acts-zh.html state:', JSON.stringify({ lang: zhCheck.lang, title: zhCheck.title, langSwitchHref: zhCheck.langSwitchHref, hudText: zhCheck.hudText, actOpt1: zhCheck.actOpt1, heroSor: zhCheck.heroSor, sceneSelectedText: zhCheck.sceneSelectedText, sampleDraws: zhCheck.charSheetDraws.slice(0, 14), })) if (zhCheck.lang !== 'CHI') { throw new Error(`Expected acts-zh.html lang === 'CHI', got ${zhCheck.lang}`) } if (!zhCheck.langSwitchHref?.endsWith('acts-en.html?act=1')) { throw new Error(`Expected #lang-switch href ending with 'acts-en.html?act=1', got ${zhCheck.langSwitchHref}`) } if (!zhCheck.actOpt1.includes('第一章') || !zhCheck.heroSor.includes('法师')) { throw new Error(`Expected Chinese act/hero options in acts-zh.html, got ${JSON.stringify(zhCheck)}`) } await saveScreenshot('acts-zh-panels.png') }) // ── Phase C: Verify acts-en.html (English ENG edition — strict 0 CJK audit) ── await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9243, async ({ evalJs, saveScreenshot, waitForGameReady }) => { await waitForGameReady() const enCheck = await evalJs<{ lang: string title: string langSwitchHref: string | null domTexts: Record cjkDomViolations: string[] cjkCanvasViolations: string[] sampleCanvasDraws: string[] }>(`(() => { const inst = window.__d2webHudInstance; const cjkRe = /[\\u4e00-\\u9fff]/; // 1. Check all DOM texts except #lang-switch (which shows "Lang: ZH") const domTexts = { title: document.title, headerH1: document.querySelector('header h1')?.textContent || '', hud: document.getElementById('hud')?.textContent || '', heroOptions: Array.from(document.querySelectorAll('#hero option')).map(el => el.textContent || '').join(' | '), actOptions: Array.from(document.querySelectorAll('#act option')).map(el => el.textContent || '').join(' | '), sceneGroups: Array.from(document.querySelectorAll('#scene optgroup')).map(el => el.getAttribute('label') || '').join(' | '), sceneOptions: Array.from(document.querySelectorAll('#scene option')).map(el => el.textContent || '').join(' | '), variantOptions: Array.from(document.querySelectorAll('#variant option')).map(el => el.textContent || '').join(' | '), lightingOptions: Array.from(document.querySelectorAll('#lighting option')).map(el => el.textContent || '').join(' | '), densityOptions: Array.from(document.querySelectorAll('#density option')).map(el => el.textContent || '').join(' | '), eliteOptions: Array.from(document.querySelectorAll('#elite option')).map(el => el.textContent || '').join(' | '), controlLabels: Array.from(document.querySelectorAll('#controls label')).map(el => el.childNodes[0]?.textContent?.trim() || '').join(' | '), hint: document.querySelector('#controls .hint')?.textContent || '', status: document.getElementById('status')?.textContent || '', dialog: document.getElementById('dialog')?.textContent || '', labels: document.getElementById('labels')?.textContent || '', }; const cjkDomViolations = []; for (const [k, v] of Object.entries(domTexts)) { if (cjkRe.test(v)) { cjkDomViolations.push(k + ': ' + v); } } // 2. Intercept all Canvas fillText/strokeText calls across all HUD panels const canvasDraws = []; const origFill = CanvasRenderingContext2D.prototype.fillText; const origStroke = CanvasRenderingContext2D.prototype.strokeText; CanvasRenderingContext2D.prototype.fillText = function(text, ...rest) { canvasDraws.push(String(text)); return origFill.call(this, text, ...rest); }; CanvasRenderingContext2D.prototype.strokeText = function(text, ...rest) { canvasDraws.push(String(text)); return origStroke.call(this, text, ...rest); }; try { // Exercise Quest Log, Waypoint, Stash, Cube, Character, Skill Tree, Inventory, NPC Menu, and Vendor Tabs for (const left of ['quest', 'waypoint', 'stash', 'cube', 'char']) { if (inst.leftPanel !== left) inst.toggleLeftPanel(left); inst.render(performance.now(), false); } for (const right of ['skill', 'inv']) { if (inst.rightPanel !== right) inst.toggleRightPanel(right); inst.render(performance.now(), false); } inst.openNpcMenuByName('Akara', 400, 220); inst.render(performance.now(), false); inst.worldPanels.npcMenu = null; inst.openVendorSession('Charsi', 'trade', 0); for (const tabId of ['armor', 'weapons1', 'weapons2', 'misc']) { inst.worldPanels.selectVendorTab(tabId); const placements = inst.worldPanels.vendorTabPlacements[tabId] || []; for (const p of placements) { inst.worldPanels.hoveredVendorItem = { item: p.item, x: 200, y: 200, col: p.col, row: p.row }; inst.render(performance.now(), false); } } inst.worldPanels.hoveredVendorItem = null; // Leave char + inv open with helm tooltip hovered for screenshot if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char'); if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv'); inst.inventory.handleMouseMove(561, 93); inst.render(performance.now(), false); } finally { CanvasRenderingContext2D.prototype.fillText = origFill; CanvasRenderingContext2D.prototype.strokeText = origStroke; } const cjkCanvasViolations = canvasDraws.filter(t => cjkRe.test(t)); return { lang: inst.lang, title: document.title, langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null, domTexts, cjkDomViolations, cjkCanvasViolations, sampleCanvasDraws: Array.from(new Set(canvasDraws)).slice(0, 25), }; })()`) console.log('4. Verified acts-en.html state:', JSON.stringify({ lang: enCheck.lang, title: enCheck.title, langSwitchHref: enCheck.langSwitchHref, hud: enCheck.domTexts.hud, actOptions: enCheck.domTexts.actOptions, heroOptions: enCheck.domTexts.heroOptions, cjkDomViolations: enCheck.cjkDomViolations, cjkCanvasViolations: enCheck.cjkCanvasViolations, sampleCanvasDraws: enCheck.sampleCanvasDraws, })) if (enCheck.lang !== 'ENG') { throw new Error(`Expected acts-en.html lang === 'ENG', got ${enCheck.lang}`) } if (!enCheck.langSwitchHref?.endsWith('acts-zh.html?act=1')) { throw new Error(`Expected #lang-switch href ending with 'acts-zh.html?act=1', got ${enCheck.langSwitchHref}`) } if (enCheck.cjkDomViolations.length > 0) { throw new Error(`Found CJK in acts-en.html DOM: ${JSON.stringify(enCheck.cjkDomViolations)}`) } if (enCheck.cjkCanvasViolations.length > 0) { throw new Error(`Found CJK in acts-en.html Canvas HUD: ${JSON.stringify(enCheck.cjkCanvasViolations)}`) } await saveScreenshot('acts-en-panels.png') }) // ── Phase D: Verify acts.html?act=2&lang=en query override ── await withChromePage(`${baseUrl}/acts.html?act=2&lang=en`, 'acts.html', 9244, async ({ evalJs, waitForGameReady }) => { await waitForGameReady() const overrideCheck = await evalJs<{ lang: string hud: string langSwitchHref: string | null }>(`({ lang: window.__d2webHudInstance.lang, hud: document.getElementById('hud')?.textContent || '', langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null, })`) console.log('5. Verified /acts.html?act=2&lang=en override:', JSON.stringify(overrideCheck)) if (overrideCheck.lang !== 'ENG' || !overrideCheck.hud.includes('Act 2')) { throw new Error(`Unexpected /acts.html?act=2&lang=en state: ${JSON.stringify(overrideCheck)}`) } }) console.log('✅ All Issue #503 Headless Browser E2E Checks PASSED!') } finally { await server.close() } } main().catch(err => { console.error('❌ Issue #503 Browser Verification Failed:', err) process.exit(1) })