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

795 lines
32 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, record 1 pending kill (unflushed), disable kill-stats via DOM toggle ONLY,
// verify pending kill was flushed on deactivate (Total=1), verify kills while disabled are NOT recorded,
// and verify disabled state persists across play.html reload.
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);
// Emit 1 monster kill BEFORE disabling (leave unflushed in memory so deactivate() must flush it)
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: 300, classId: 19, x: 5005, y: 5005, mode: 1, lifePct: 128 },
});
emit({ type: 'UnitMode', unitType: 'monster', id: 300, mode: 0, x: 5005, y: 5005, lifePct: 0 });
// Toggle kill-stats off via DOM checkbox ONLY (do NOT call app.plugins.setEnabled manually)
const toggle = document.querySelector('[data-plugin-toggle="kill-stats"]');
toggle?.click();
})()`)
// Wait for async deactivation & pending kill flush triggered by checkbox change event
let deactivatedByToggle = false
for (let i = 0; i < 40; i++) {
await sleep(100)
deactivatedByToggle = await playCdp.evaluate<boolean>(
`!window.__d2PlayApp.plugins.getInstance('kill-stats') && !document.getElementById('d2-toolbar-plugin-kill-stats-open')`,
)
if (deactivatedByToggle) break
}
assert(
deactivatedByToggle,
'Clicking [data-plugin-toggle="kill-stats"] must deactivate kill-stats and remove #d2-toolbar-plugin-kill-stats-open without manual setEnabled()',
)
// Emit monster kill while kill-stats is disabled (must NOT be recorded)
await playCdp.evaluate(`(() => {
const emit = window.__d2EmitPluginTestEvent;
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=1 (from pre-disable flush)
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 === '1',
`Expected Total=1 (flushed on disable, while post-disable kill 301 was ignored), 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`)
// Reload play.html while kill-stats is disabled and verify disabled state persists across reload
await playCdp.send('Page.reload')
for (let i = 0; i < 100; i++) {
await sleep(150)
const ready = await playCdp.evaluate<boolean>(
'Boolean(window.__d2PlayApp && window.__d2PlayApp.ready && window.__d2PlayApp.plugins)',
)
if (ready) break
}
await playCdp.evaluate(`window.__d2PlayApp.plugins.whenStarted()`)
const persistedDisabledAfterReload = await playCdp.evaluate<{
isEnabled: boolean
hasInstance: boolean
hasShortcutBtn: boolean
}>(`(() => ({
isEnabled: window.__d2PlayApp.plugins.isEnabled('kill-stats'),
hasInstance: Boolean(window.__d2PlayApp.plugins.getInstance('kill-stats')),
hasShortcutBtn: Boolean(document.getElementById('d2-toolbar-plugin-kill-stats-open')),
}))()`)
assert(!persistedDisabledAfterReload.isEnabled, 'kill-stats must remain disabled after reloading play.html')
assert(!persistedDisabledAfterReload.hasInstance, 'kill-stats instance must remain null after reloading play.html')
assert(
!persistedDisabledAfterReload.hasShortcutBtn,
'#d2-toolbar-plugin-kill-stats-open must remain absent after reloading play.html while disabled',
)
// 5. Re-open plugin menu on reloaded play.html, re-enable kill-stats via DOM toggle ONLY -> verify #d2-stats-disabled-notice hides live & kills record
await playCdp.evaluate(`(() => {
document.getElementById('d2-toolbar-plugins-btn')?.click();
const toggle = document.querySelector('[data-plugin-toggle="kill-stats"]');
toggle?.click();
})()`)
let reenabledByToggle = false
for (let i = 0; i < 40; i++) {
await sleep(100)
reenabledByToggle = await playCdp.evaluate<boolean>(
`Boolean(window.__d2PlayApp.plugins.getInstance('kill-stats')) && Boolean(document.getElementById('d2-toolbar-plugin-kill-stats-open'))`,
)
if (reenabledByToggle) break
}
assert(reenabledByToggle, 'Clicking [data-plugin-toggle="kill-stats"] must re-enable kill-stats without manual setEnabled()')
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 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);
emitServerEvent({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
emitServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 'player', id: 1, classId: 1, name: 'PluginSorc', x: 5000, y: 5000, mode: 1, lifePct: 128 },
});
emitServerEvent({
type: 'UnitAssign',
unit: { unitType: 'monster', id: 302, classId: 156, x: 5010, y: 5010, mode: 1, lifePct: 128, isBoss: true },
});
emitServerEvent({ type: 'UnitMode', unitType: 'monster', id: 302, mode: 0, x: 5010, y: 5010, lifePct: 0 });
await app.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 === '2') break
}
assert(totalAfterReenable === '2', `Expected Total=2 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 changing flowState.stage or opening in-game Esc menu / HUD panels
const escIsolation = await playCdp.evaluate<{
modalOpenBefore: boolean
modalOpenAfter: boolean
hudListenerTriggered: boolean
stageBefore: string
stageAfter: string
escMenuBefore: boolean
escMenuAfter: boolean
}>(`(() => {
const app = window.__d2PlayApp;
const overlay = document.querySelector('[data-d2-modal-overlay="plugins"]');
const modalOpenBefore = Boolean(overlay && !overlay.hasAttribute('hidden'));
const stageBefore = app.flowState.stage;
const escMenuBefore = Boolean(app.hudModel.getSnapshot().panels.escapeMenu || app.hudManager.escapeMenuOpen);
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'));
const stageAfter = app.flowState.stage;
const escMenuAfter = Boolean(app.hudModel.getSnapshot().panels.escapeMenu || app.hudManager.escapeMenuOpen);
return {
modalOpenBefore,
modalOpenAfter,
hudListenerTriggered: hudBubbleKeySeen,
stageBefore,
stageAfter,
escMenuBefore,
escMenuAfter,
};
})()`)
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(
escIsolation.stageAfter === escIsolation.stageBefore,
`Escape key while plugin modal is open must not change flowState.stage (${escIsolation.stageBefore} -> ${escIsolation.stageAfter})`,
)
assert(
!escIsolation.escMenuBefore && !escIsolation.escMenuAfter,
'Escape key while plugin modal is open must not open the in-game Escape menu',
)
// Ensure 0 console errors prior to intentional corrupt-JSON injection test
assert(
playCdp.consoleErrors.length === 0,
`play.html had console errors before corrupt-JSON test: ${JSON.stringify(playCdp.consoleErrors)}`,
)
assert(
statsCdp.consoleErrors.length === 0,
`stats page had console errors before corrupt-JSON test: ${JSON.stringify(statsCdp.consoleErrors)}`,
)
// 7. Inject corrupt JSON into d2web.plugins.v1, reload play.html, verify ⚠ badge + #d2-plugin-menu-alert + preserved raw value,
// then click #d2-plugin-menu-reset and verify ⚠ clears, alert hides, and valid JSON is persisted.
await playCdp.evaluate(`localStorage.setItem('d2web.plugins.v1', '{corrupt-json')`)
await playCdp.send('Page.reload')
for (let i = 0; i < 100; i++) {
await sleep(150)
const ready = await playCdp.evaluate<boolean>(
'Boolean(window.__d2PlayApp && window.__d2PlayApp.ready && window.__d2PlayApp.plugins)',
)
if (ready) break
}
await playCdp.evaluate(`window.__d2PlayApp.plugins.whenStarted()`)
// Verify expected [PluginConfigStore] console.error was logged on corrupt JSON and filter it out on both tabs
const corruptLogs = playCdp.consoleErrors.filter((msg) =>
msg.includes('[PluginConfigStore] Corrupt JSON in d2web.plugins.v1'),
)
assert(corruptLogs.length >= 1, 'Expected [PluginConfigStore] Corrupt JSON console.error on corrupt d2web.plugins.v1')
const unexpectedPlayLogs = playCdp.consoleErrors.filter(
(msg) => !msg.includes('[PluginConfigStore] Corrupt JSON in d2web.plugins.v1'),
)
playCdp.consoleErrors.length = 0
playCdp.consoleErrors.push(...unexpectedPlayLogs)
const unexpectedStatsLogs = statsCdp.consoleErrors.filter(
(msg) => !msg.includes('[PluginConfigStore] Corrupt JSON in d2web.plugins.v1'),
)
statsCdp.consoleErrors.length = 0
statsCdp.consoleErrors.push(...unexpectedStatsLogs)
const corruptAlertState = await playCdp.evaluate<{
toolbarLabel: string
rawBeforeReset: string | null
alertVisible: boolean
alertRole: string | null
alertText: string
}>(`(() => {
const btn = document.getElementById('d2-toolbar-plugins-btn');
const toolbarLabel = btn?.textContent?.trim() ?? '';
btn?.click();
const alertEl = document.getElementById('d2-plugin-menu-alert');
return {
toolbarLabel,
rawBeforeReset: localStorage.getItem('d2web.plugins.v1'),
alertVisible: Boolean(alertEl && !alertEl.hidden),
alertRole: alertEl?.getAttribute('role') ?? null,
alertText: alertEl?.textContent?.trim() ?? '',
};
})()`)
assert(
corruptAlertState.toolbarLabel.includes('⚠'),
`Expected #d2-toolbar-plugins-btn to include ⚠ on corrupt JSON, got "${corruptAlertState.toolbarLabel}"`,
)
assert(
corruptAlertState.rawBeforeReset === '{corrupt-json',
`Raw corrupt localStorage value must NOT be overwritten before reset, got ${JSON.stringify(corruptAlertState.rawBeforeReset)}`,
)
assert(corruptAlertState.alertVisible, '#d2-plugin-menu-alert must be visible when storage is corrupt')
assert(corruptAlertState.alertRole === 'alert', 'Expected #d2-plugin-menu-alert to have role="alert"')
assert(corruptAlertState.alertText.length > 0, '#d2-plugin-menu-alert must contain a diagnostic message')
const shot5 = join(outDir, '05-plugin-menu-corrupt-alert.png')
const shot5Bytes = await playCdp.captureScreenshot(shot5)
console.log(`[verify-plugin-menu] Captured ${shot5} (${shot5Bytes} bytes) — Corrupt config alert banner`)
await playCdp.evaluate(`document.getElementById('d2-plugin-menu-reset')?.click()`)
await sleep(250)
const afterResetState = await playCdp.evaluate<{
toolbarLabel: string
alertHidden: boolean
rawAfterReset: string | null
}>(`(() => {
const btn = document.getElementById('d2-toolbar-plugins-btn');
const alertEl = document.getElementById('d2-plugin-menu-alert');
return {
toolbarLabel: btn?.textContent?.trim() ?? '',
alertHidden: Boolean(alertEl && alertEl.hidden),
rawAfterReset: localStorage.getItem('d2web.plugins.v1'),
};
})()`)
assert(
!afterResetState.toolbarLabel.includes('⚠'),
`Expected ⚠ to clear from #d2-toolbar-plugins-btn after reset, got "${afterResetState.toolbarLabel}"`,
)
assert(afterResetState.alertHidden, '#d2-plugin-menu-alert must be hidden after clicking #d2-plugin-menu-reset')
const parsedAfterReset = JSON.parse(afterResetState.rawAfterReset ?? '{}') as {
version?: number
plugins?: Record<string, { enabled?: boolean }>
}
assert(parsedAfterReset.version === 1, 'Expected reset d2web.plugins.v1 to have version: 1')
assert(
parsedAfterReset.plugins?.['kill-stats']?.enabled === true,
'Expected reset d2web.plugins.v1 to restore kill-stats enabled: true',
)
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)
})