382 lines
16 KiB
TypeScript
382 lines
16 KiB
TypeScript
/**
|
|
* 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<void>(resolve => setTimeout(resolve, ms))
|
|
|
|
interface CdpSession {
|
|
evalJs: <T = any>(expression: string) => Promise<T>
|
|
saveScreenshot: (filename: string) => Promise<void>
|
|
waitForGameReady: () => Promise<void>
|
|
}
|
|
|
|
async function withChromePage<T>(
|
|
url: string,
|
|
matchSubstr: string,
|
|
debugPort: number,
|
|
fn: (session: CdpSession) => Promise<T>,
|
|
): Promise<T> {
|
|
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<void>(resolve => {
|
|
ws.onopen = () => resolve()
|
|
})
|
|
|
|
let idCounter = 1
|
|
const sendCommand = <R = any>(method: string, params: Record<string, unknown> = {}): Promise<R> =>
|
|
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 <R = any>(expression: string): Promise<R> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
for (let i = 0; i < 120; i++) {
|
|
const ready = await evalJs<boolean>(
|
|
`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<void> {
|
|
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<string, string>
|
|
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)
|
|
})
|