diablo2-web/tools/verify-plugin-menu-browser.ts

580 lines
23 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* Headless Chrome CDP End-to-End Browser Verification for the Plugin System & Modal Menu:
* - Verifies `#d2-toolbar-plugins-btn` opens `[data-d2-modal-overlay="plugins"]`
* - Verifies bilingual (`zh` / `en`) rendering of plugin cards, status badges, settings, and actions
* - Verifies toggling `showToolbarShortcut` (`[data-plugin-setting="kill-stats:showToolbarShortcut"]`) dynamically hides/shows `#d2-toolbar-plugin-kill-stats-open`
* - Verifies disabling `kill-stats` (`[data-plugin-toggle="kill-stats"]`):
* - Removes `#d2-toolbar-plugin-kill-stats-open`
* - Stops recording simulated monster kills while disabled
* - Displays `#d2-stats-disabled-notice` on `/plugins/kill-stats/index.html`
* - Verifies re-enabling `kill-stats`:
* - Live-updates `/plugins/kill-stats/index.html` via `storage` event to hide `#d2-stats-disabled-notice`
* - Resumes recording simulated monster kills
* - Verifies pressing `Escape` while the plugin modal is open closes the modal without triggering in-game Esc menu or HUD hotkeys
* - Verifies zero console errors and zero uncaught page exceptions
*/
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 parseOutDir(): string {
const args = process.argv.slice(2)
for (let i = 0; i < args.length; i++) {
const arg = args[i]!
if (arg === '--out' && args[i + 1]) return resolve(args[i + 1]!)
if (arg.startsWith('--out=')) return resolve(arg.slice('--out='.length))
}
return resolve(ROOT_DIR, 'scratch/verify-plugin-menu')
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
function assert(cond: unknown, message: string): asserts cond {
if (!cond) {
throw new Error(`Assertion failed: ${message}`)
}
}
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
}
}
}
async function openNewPageTarget(
cdpPort: string,
targetUrl: string,
): Promise<{ readonly id: string; readonly webSocketDebuggerUrl: string }> {
const res = await fetch(
`http://127.0.0.1:${cdpPort}/json/new?${encodeURIComponent(targetUrl)}`,
{ method: 'PUT' },
)
if (!res.ok) {
throw new Error(`Failed to open new tab for ${targetUrl}: HTTP ${res.status}`)
}
return (await res.json()) as { id: string; webSocketDebuggerUrl: string }
}
async function run(): Promise<void> {
const chromeBin = findChromeBinary()
const outDir = parseOutDir()
mkdirSync(outDir, { recursive: true })
const publicDir = process.env.D2W_PUBLIC_DIR
? resolve(process.env.D2W_PUBLIC_DIR)
: resolve(ROOT_DIR, 'public')
const tilesIndexPath = join(publicDir, 'act-packs/tiles/index.json')
if (!existsSync(tilesIndexPath)) {
throw new Error(
`Missing required packed tiles at ${tilesIndexPath}. Set D2W_PUBLIC_DIR=/usr/local/google/home/taodao/diablo2-web/public`,
)
}
let viteServer: ViteDevServer | null = null
const userDataDir = mkdtempSync(join(tmpdir(), 'd2-verify-plugin-menu-'))
let chromeProc: ReturnType<typeof spawn> | null = null
let playCdp: CdpSession | null = null
let statsCdp: CdpSession | null = null
try {
viteServer = await createServer({
root: ROOT_DIR,
publicDir,
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 playUrl = `http://127.0.0.1:${port}/play.html`
const statsUrl = `http://127.0.0.1:${port}/plugins/kill-stats/index.html`
console.log(`[verify-plugin-menu] Vite server listening at http://127.0.0.1:${port}`)
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=1440,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')
playCdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl)
await playCdp.send('Page.navigate', { url: playUrl })
for (let i = 0; i < 120; i++) {
await sleep(200)
const ready = await playCdp.evaluate<boolean>(
'Boolean(window.__d2PlayApp && window.__d2PlayApp.ready && window.__d2PlayApp.plugins)',
)
if (ready) break
}
const playReady = await playCdp.evaluate<boolean>(
'Boolean(window.__d2PlayApp && window.__d2PlayApp.ready && window.__d2PlayApp.plugins)',
)
assert(playReady, 'play.html __d2PlayApp failed to reach ready=true')
await playCdp.evaluate('window.__d2PlayApp.plugins.whenStarted()')
// 1. Open plugin modal menu via #d2-toolbar-plugins-btn
const openStateZh = await playCdp.evaluate<{
btnExists: boolean
btnDisabled: boolean
btnText: string
modalOpen: boolean
cardExists: boolean
shortcutExists: boolean
titleText: string
}>(`(() => {
const btn = document.getElementById('d2-toolbar-plugins-btn');
btn?.click();
const overlay = document.querySelector('[data-d2-modal-overlay="plugins"]');
const card = document.querySelector('[data-plugin-id="kill-stats"]');
const shortcut = document.getElementById('d2-toolbar-plugin-kill-stats-open');
return {
btnExists: Boolean(btn),
btnDisabled: Boolean(btn?.disabled),
btnText: btn?.textContent?.trim() ?? '',
modalOpen: Boolean(overlay && !overlay.hasAttribute('hidden')),
cardExists: Boolean(card),
shortcutExists: Boolean(shortcut),
titleText: overlay?.querySelector('.d2-plugin-menu__title')?.textContent?.trim() ?? '',
};
})()`)
assert(openStateZh.btnExists, '#d2-toolbar-plugins-btn must exist')
assert(!openStateZh.btnDisabled, '#d2-toolbar-plugins-btn must be enabled after PluginHost starts')
assert(openStateZh.modalOpen, 'Plugin modal overlay must be open after clicking #d2-toolbar-plugins-btn')
assert(openStateZh.cardExists, 'Plugin card [data-plugin-id="kill-stats"] must exist')
assert(openStateZh.shortcutExists, '#d2-toolbar-plugin-kill-stats-open must exist initially')
assert(openStateZh.titleText === '插件管理', `Expected Chinese title "插件管理", got "${openStateZh.titleText}"`)
const shot1 = join(outDir, '01-plugin-menu-open-zh.png')
const shot1Bytes = await playCdp.captureScreenshot(shot1)
console.log(`[verify-plugin-menu] Captured ${shot1} (${shot1Bytes} bytes) — Plugin menu open (ZH)`)
// 2. Toggle language to English and verify English labels in plugin modal
await playCdp.evaluate(`(() => {
document.getElementById('d2-toolbar-lang-en')?.click();
})()`)
const openStateEn = await playCdp.evaluate<{
titleText: string
btnText: string
resetText: string
}>(`(() => {
const overlay = document.querySelector('[data-d2-modal-overlay="plugins"]');
return {
titleText: overlay?.querySelector('.d2-plugin-menu__title')?.textContent?.trim() ?? '',
btnText: document.getElementById('d2-toolbar-plugins-btn')?.textContent?.trim() ?? '',
resetText: document.getElementById('d2-plugin-menu-reset')?.textContent?.trim() ?? '',
};
})()`)
assert(openStateEn.titleText === 'Plugins', `Expected English title "Plugins", got "${openStateEn.titleText}"`)
assert(openStateEn.btnText.includes('Plugins'), `Expected toolbar button "Plugins", got "${openStateEn.btnText}"`)
assert(openStateEn.resetText === 'Reset All to Defaults', `Expected "Reset All to Defaults", got "${openStateEn.resetText}"`)
const shot2 = join(outDir, '02-plugin-menu-open-en.png')
const shot2Bytes = await playCdp.captureScreenshot(shot2)
console.log(`[verify-plugin-menu] Captured ${shot2} (${shot2Bytes} bytes) — Plugin menu open (EN)`)
// Switch back to Chinese
await playCdp.evaluate(`(() => {
document.getElementById('d2-toolbar-lang-zh')?.click();
})()`)
// 3. Toggle showToolbarShortcut off -> verify #d2-toolbar-plugin-kill-stats-open disappears, then toggle back on
const afterShortcutOff = await playCdp.evaluate<boolean>(`(() => {
const cb = document.querySelector('[data-plugin-setting="kill-stats:showToolbarShortcut"]');
cb?.click();
return Boolean(document.getElementById('d2-toolbar-plugin-kill-stats-open'));
})()`)
assert(!afterShortcutOff, '#d2-toolbar-plugin-kill-stats-open must be removed when showToolbarShortcut is unchecked')
const shot3 = join(outDir, '03-plugin-shortcut-hidden.png')
const shot3Bytes = await playCdp.captureScreenshot(shot3)
console.log(`[verify-plugin-menu] Captured ${shot3} (${shot3Bytes} bytes) — Toolbar shortcut hidden`)
const afterShortcutOn = await playCdp.evaluate<boolean>(`(() => {
const cb = document.querySelector('[data-plugin-setting="kill-stats:showToolbarShortcut"]');
cb?.click();
return Boolean(document.getElementById('d2-toolbar-plugin-kill-stats-open'));
})()`)
assert(afterShortcutOn, '#d2-toolbar-plugin-kill-stats-open must reappear when showToolbarShortcut is checked')
// 4. Attach fake game server, disable kill-stats, verify kills are NOT recorded while disabled, and verify #d2-stats-disabled-notice in stats page
await playCdp.evaluate(`(async () => {
const app = window.__d2PlayApp;
const session = app.session;
session.loginHostValue = 'www.laiseek.xyz';
session.usernameValue = 'pluginacct';
session.selectedRealmValue = { title: 'D2CS', description: 'Realm' };
session.selectedCharValue = 'PluginSorc';
session.charactersValue = [
{
name: 'PluginSorc',
charClass: 1,
level: 88,
hardcore: true,
expansion: true,
ladder: true,
rawFlags: 0,
act: 5,
},
];
let emitServerEvent = null;
const fakeAdapter = {
isConnected: true,
stats: { packetsIn: 0, packetsOut: 0, bytesIn: 0, bytesOut: 0, rttMs: 12, unknownPackets: 0 },
connect: async () => {},
disconnect: () => {},
send: () => {},
onEvent: (cb) => {
emitServerEvent = cb;
return () => { emitServerEvent = null; };
},
};
session.attachGameServer(fakeAdapter);
window.__d2EmitPluginTestEvent = (ev) => emitServerEvent && emitServerEvent(ev);
// Toggle kill-stats off via DOM checkbox
const toggle = document.querySelector('[data-plugin-toggle="kill-stats"]');
toggle?.click();
await app.plugins.setEnabled('kill-stats', false);
})()`)
const disabledShortcutExists = await playCdp.evaluate<boolean>(
`Boolean(document.getElementById('d2-toolbar-plugin-kill-stats-open'))`,
)
assert(!disabledShortcutExists, '#d2-toolbar-plugin-kill-stats-open must be removed when kill-stats is disabled')
// Emit monster kill while kill-stats is disabled
await playCdp.evaluate(`(() => {
const emit = window.__d2EmitPluginTestEvent;
emit({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
emit({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 'player', id: 1, classId: 1, name: 'PluginSorc', x: 5000, y: 5000, mode: 1, lifePct: 128 },
});
emit({
type: 'UnitAssign',
unit: { unitType: 'monster', id: 301, classId: 156, x: 5010, y: 5010, mode: 1, lifePct: 128, isBoss: true },
});
emit({ type: 'UnitMode', unitType: 'monster', id: 301, mode: 0, x: 5010, y: 5010, lifePct: 0 });
})()`)
// Open /plugins/kill-stats/index.html in second tab and verify #d2-stats-disabled-notice is visible & Total=0
const statsTarget = await openNewPageTarget(cdpPort, statsUrl)
statsCdp = await CdpSession.connect(statsTarget.webSocketDebuggerUrl)
for (let i = 0; i < 100; i++) {
await sleep(150)
const ready = await statsCdp.evaluate<boolean>('Boolean(window.__d2StatsApp && window.__d2StatsApp.ready)')
if (ready) break
}
const statsDisabledState = await statsCdp.evaluate<{
noticeVisible: boolean
noticeText: string
valTotal: string
}>(`(() => {
const notice = document.getElementById('d2-stats-disabled-notice');
return {
noticeVisible: Boolean(notice && !notice.hidden),
noticeText: notice?.textContent?.trim() ?? '',
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
};
})()`)
assert(statsDisabledState.noticeVisible, '#d2-stats-disabled-notice must be visible when kill-stats is disabled')
assert(statsDisabledState.valTotal === '0', `Expected Total=0 while kill-stats was disabled, got ${statsDisabledState.valTotal}`)
const shot4 = join(outDir, '04-stats-disabled-notice.png')
const shot4Bytes = await statsCdp.captureScreenshot(shot4)
console.log(`[verify-plugin-menu] Captured ${shot4} (${shot4Bytes} bytes) — Disabled notice visible on stats page`)
// 5. Re-enable kill-stats in play.html -> verify #d2-stats-disabled-notice hides live via storage event & kills record after GameFlags
await playCdp.evaluate(`(async () => {
const toggle = document.querySelector('[data-plugin-toggle="kill-stats"]');
toggle?.click();
await window.__d2PlayApp.plugins.setEnabled('kill-stats', true);
})()`)
let noticeHiddenAfterReenable = false
for (let i = 0; i < 30; i++) {
await sleep(100)
noticeHiddenAfterReenable = await statsCdp.evaluate<boolean>(
`Boolean(document.getElementById('d2-stats-disabled-notice')?.hidden)`,
)
if (noticeHiddenAfterReenable) break
}
assert(noticeHiddenAfterReenable, '#d2-stats-disabled-notice must hide automatically when kill-stats is re-enabled')
await playCdp.evaluate(`(async () => {
const emit = window.__d2EmitPluginTestEvent;
emit({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
emit({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 'player', id: 1, classId: 1, name: 'PluginSorc', x: 5000, y: 5000, mode: 1, lifePct: 128 },
});
emit({
type: 'UnitAssign',
unit: { unitType: 'monster', id: 302, classId: 156, x: 5010, y: 5010, mode: 1, lifePct: 128, isBoss: true },
});
emit({ type: 'UnitMode', unitType: 'monster', id: 302, mode: 0, x: 5010, y: 5010, lifePct: 0 });
await window.__d2PlayApp.plugins.getInstance('kill-stats').tracker.flushNow();
})()`)
let totalAfterReenable = ''
for (let i = 0; i < 30; i++) {
await sleep(100)
totalAfterReenable = await statsCdp.evaluate<string>(
`document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? ''`,
)
if (totalAfterReenable === '1') break
}
assert(totalAfterReenable === '1', `Expected Total=1 after re-enabling kill-stats, got ${totalAfterReenable}`)
// Clean up recorded character in stats page
await statsCdp.evaluate(`(async () => {
await window.__d2StatsApp.repository.clearAll();
await window.__d2StatsApp.refresh();
})()`)
// 6. Verify Escape key closes the plugin modal WITHOUT triggering in-game Esc menu or HUD panels
const escIsolation = await playCdp.evaluate<{
modalOpenBefore: boolean
modalOpenAfter: boolean
hudListenerTriggered: boolean
}>(`(() => {
const overlay = document.querySelector('[data-d2-modal-overlay="plugins"]');
const modalOpenBefore = Boolean(overlay && !overlay.hasAttribute('hidden'));
let hudBubbleKeySeen = false;
const onBubbleKey = () => { hudBubbleKeySeen = true; };
window.addEventListener('keydown', onBubbleKey, false);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true }));
window.removeEventListener('keydown', onBubbleKey, false);
const modalOpenAfter = Boolean(overlay && !overlay.hasAttribute('hidden'));
return {
modalOpenBefore,
modalOpenAfter,
hudListenerTriggered: hudBubbleKeySeen,
};
})()`)
assert(escIsolation.modalOpenBefore, 'Plugin modal must be open before Escape key')
assert(!escIsolation.modalOpenAfter, 'Plugin modal must close on Escape key')
assert(!escIsolation.hudListenerTriggered, 'Escape key while plugin modal is open must not reach bubble-phase HUD listeners')
assert(
playCdp.consoleErrors.length === 0,
`play.html had console errors: ${JSON.stringify(playCdp.consoleErrors)}`,
)
assert(
playCdp.pageExceptions.length === 0,
`play.html had uncaught page exceptions: ${JSON.stringify(playCdp.pageExceptions)}`,
)
assert(
statsCdp.consoleErrors.length === 0,
`stats page had console errors: ${JSON.stringify(statsCdp.consoleErrors)}`,
)
assert(
statsCdp.pageExceptions.length === 0,
`stats page had uncaught page exceptions: ${JSON.stringify(statsCdp.pageExceptions)}`,
)
console.log('[verify-plugin-menu] ALL END-TO-END BROWSER ASSERTIONS PASSED (0 console errors, 0 page exceptions)')
} finally {
await statsCdp?.close()
await playCdp?.close()
if (chromeProc && !chromeProc.killed) {
chromeProc.kill('SIGKILL')
}
if (viteServer) {
await viteServer.close()
}
rmSync(userDataDir, { recursive: true, force: true })
}
}
run().catch((err) => {
console.error('[verify-plugin-menu] FAILED:', err)
process.exit(1)
})