diablo2-web/tools/verify-kill-stats-browser.ts

852 lines
33 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* Headless Chrome CDP End-to-End Browser Verification for Kill Statistics:
* - Verifies `play.html` toolbar entry (`#d2-toolbar-killstats` -> `stats.html`, `target="_blank"`)
* - Drives `OnlineSession` + `KillTracker` in `play.html` with simulated D2GS `ServerEvent`s:
* - Solo kills: Andariel (`classId: 156`, double death event `0x08`+`0x09`), Pindleskin (`classId: 440`, `superUniqueId: 52`), Fallen (`classId: 19`)
* - Ignored non-hostile deaths: Mercenary (`0x81`), player summon (`ownerId`), friendly `Align !== 0` (`classId: 363`)
* - Party kill: `RosterUpdate` (`0x5B`) + Fallen (`classId: 19`)
* - Opens `stats.html` in a second tab in the same browser context:
* - Verifies local `@font-face` (`D2Exocet`, `D2Formal436`) and isolated `100vh` scroll containers
* - Verifies Account -> Character tree, Difficulty Summary Cards, 86-row Boss/SuperUnique Board, and Monster Type Table
* - Verifies Solo-Only toggle (`4` -> `3` -> `4`) and `中 / EN` language toggle
* - Verifies real-time cross-tab `BroadcastChannel('d2web-kill-stats')` auto-refresh (`4` -> `5` without reload)
* - Verifies G-counter JSON export + idempotent re-import via `DOM.setFileInputFiles`
* - Verifies tampered JSON (`soloCount > count`) rejection with `[role="alert"]` error banner
* - Verifies Character Deletion (`window.confirm` dialog) and empty state rendering
* - Verifies zero console errors and zero uncaught page exceptions across both tabs
*/
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-kill-stats')
}
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[] = []
readonly dialogsOpened: Array<{ readonly type: string; readonly message: string }> = []
autoAcceptDialogs = true
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))
} else if (msg.method === 'Page.javascriptDialogOpening') {
this.dialogsOpened.push({
type: String(msg.params?.type ?? ''),
message: String(msg.params?.message ?? ''),
})
void this.send('Page.handleJavaScriptDialog', { accept: this.autoAcceptDialogs })
}
}
}
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 setFileInputFiles(selector: string, filePaths: readonly string[]): Promise<void> {
await this.send('DOM.enable')
const { root } = await this.send('DOM.getDocument', { depth: -1 })
const { nodeId } = await this.send('DOM.querySelector', {
nodeId: root.nodeId,
selector,
})
if (!nodeId) {
throw new Error(`DOM.querySelector failed to find file input: ${selector}`)
}
await this.send('DOM.setFileInputFiles', {
nodeId,
files: [...filePaths],
})
await this.evaluate(`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (el) el.dispatchEvent(new Event('change', { bubbles: true }));
})()`)
}
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-kill-stats-'))
const tmpImportDir = mkdtempSync(join(tmpdir(), 'd2-verify-kill-stats-import-'))
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-kill-stats] 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 })
// 1. Wait for play.html boot + PluginHost ready and verify #d2-toolbar-plugin-kill-stats-open
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()')
const toolbarLink = await playCdp.evaluate<{
exists: boolean
legacyExists: boolean
href: string
target: string
text: string
}>(`(() => {
const el = document.getElementById('d2-toolbar-plugin-kill-stats-open');
const legacyEl = document.getElementById('d2-toolbar-killstats');
return {
exists: Boolean(el),
legacyExists: Boolean(legacyEl),
href: el?.getAttribute('href') ?? '',
target: el?.getAttribute('target') ?? '',
text: el?.textContent?.trim() ?? '',
};
})()`)
assert(toolbarLink.exists, '#d2-toolbar-plugin-kill-stats-open must exist in play.html toolbar')
assert(!toolbarLink.legacyExists, 'Legacy #d2-toolbar-killstats must not exist in play.html toolbar')
assert(
toolbarLink.href === 'plugins/kill-stats/index.html',
`Expected href="plugins/kill-stats/index.html", got ${toolbarLink.href}`,
)
assert(toolbarLink.target === '_blank', `Expected target="_blank", got ${toolbarLink.target}`)
console.log(`[verify-kill-stats] Verified play.html plugin toolbar link: href="${toolbarLink.href}" target="${toolbarLink.target}" text="${toolbarLink.text}"`)
// 2. Drive OnlineSession + KillTracker in play.html with simulated ServerEvents
await playCdp.evaluate(`(async () => {
const app = window.__d2PlayApp;
const session = app.session;
session.loginHostValue = 'www.laiseek.xyz';
session.usernameValue = 'myacct';
session.selectedRealmValue = { title: 'D2CS', description: 'Realm' };
session.selectedCharValue = 'HeroSorc';
session.charactersValue = [
{
name: 'HeroSorc',
charClass: 1,
level: 85,
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.__d2EmitTestServerEvent = (ev) => emitServerEvent && emitServerEvent(ev);
const emit = (ev) => emitServerEvent(ev);
// GameFlags: Hell difficulty (2)
emit({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
// Local player self assignment (id = 1)
emit({
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: 1,
classId: 1,
name: 'HeroSorc',
x: 5000,
y: 5000,
mode: 1,
lifePct: 128,
},
});
// Solo Kill #1: Andariel (classId 156, boss) with both MONMODE_DEATH (0) and MONMODE_DEAD (12) -> must count ONCE
emit({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: 101,
classId: 156,
x: 5010,
y: 5010,
mode: 1,
lifePct: 128,
isBoss: true,
isSuperUnique: false,
superUniqueId: 0,
},
});
emit({ type: 'UnitMode', unitType: 'monster', id: 101, mode: 0, x: 5010, y: 5010, lifePct: 0 });
emit({ type: 'UnitMode', unitType: 'monster', id: 101, mode: 12, x: 5010, y: 5010, lifePct: 0 });
// Solo Kill #2: Pindleskin (classId 440, superUniqueId 52)
emit({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: 102,
classId: 440,
x: 5020,
y: 5020,
mode: 1,
lifePct: 128,
isBoss: true,
isSuperUnique: true,
superUniqueId: 52,
},
});
emit({ type: 'UnitMode', unitType: 'monster', id: 102, mode: 12, x: 5020, y: 5020, lifePct: 0 });
// Solo Kill #3: Fallen (classId 19 = fallen1)
emit({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: 103,
classId: 19,
x: 5030,
y: 5030,
mode: 1,
lifePct: 128,
isBoss: false,
isSuperUnique: false,
superUniqueId: 0,
},
});
emit({ type: 'UnitMode', unitType: 'monster', id: 103, mode: 12, x: 5030, y: 5030, lifePct: 0 });
// Ignored #1: Mercenary (0x81 Merc assign)
emit({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 201, classId: 271, nameId: 1 });
emit({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: 201,
classId: 271,
x: 5005,
y: 5005,
mode: 1,
lifePct: 128,
},
});
emit({ type: 'UnitMode', unitType: 'monster', id: 201, mode: 12, x: 5005, y: 5005, lifePct: 0 });
// Ignored #2: Player summon (ownerId = 1)
emit({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: 202,
classId: 19,
x: 5006,
y: 5006,
mode: 1,
lifePct: 128,
ownerId: 1,
},
});
emit({ type: 'UnitMode', unitType: 'monster', id: 202, mode: 12, x: 5006, y: 5006, lifePct: 0 });
// Ignored #3: Friendly Align !== 0 unit (classId 363 = necroskeleton)
emit({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: 203,
classId: 363,
x: 5007,
y: 5007,
mode: 1,
lifePct: 128,
},
});
emit({ type: 'UnitMode', unitType: 'monster', id: 203, mode: 12, x: 5007, y: 5007, lifePct: 0 });
// Party Kill #4: Add another player to roster, then kill Fallen (id 104, classId 19)
emit({
type: 'Party',
kind: 'roster',
unitId: 2,
charName: 'PartyPaladin',
charClass: 3,
level: 80,
partyId: 1,
});
emit({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: 104,
classId: 19,
x: 5040,
y: 5040,
mode: 1,
lifePct: 128,
},
});
emit({ type: 'UnitMode', unitType: 'monster', id: 104, mode: 12, x: 5040, y: 5040, lifePct: 0 });
await app.plugins.getInstance('kill-stats').tracker.flushNow();
})()`)
// 3. Open plugins/kill-stats/index.html in a second tab in the same browser context
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 statsReady = await statsCdp.evaluate<boolean>('Boolean(window.__d2StatsApp && window.__d2StatsApp.ready)')
assert(statsReady, 'plugins/kill-stats/index.html __d2StatsApp failed to reach ready=true')
// 4. Verify isolated scroll layout, sidebar tree, summary cards, Boss/SU board, and Monster Type table
const initialSnap = await statsCdp.evaluate<{
outerOverflow: string
sidebarOverflowY: string
mainOverflowY: string
backLinkHref: string
disabledNoticeHidden: boolean
sidebarText: string
hasHeroSorc: boolean
valNormal: string
valNightmare: string
valHell: string
valTotal: string
andarielTotal: string
pindleskinTotal: string
fallenTotal: string
}>(`(() => {
const outer = document.querySelector('.d2-stats-shell') || document.getElementById('d2-stats-app');
const sidebar = document.querySelector('.d2-stats__sidebar');
const main = document.querySelector('.d2-stats__main');
const backLink = document.getElementById('d2-stats-back-link');
const disabledNotice = document.getElementById('d2-stats-disabled-notice');
const andarielRow = document.querySelector('tr[data-canonical-id="andariel"]');
const pindleskinRow = document.querySelector('tr[data-canonical-id="Pindleskin"]');
const fallenRow = document.querySelector('tr[data-monster-hcidx="19"]');
return {
outerOverflow: outer ? getComputedStyle(outer).overflow : '',
sidebarOverflowY: sidebar ? getComputedStyle(sidebar).overflowY : '',
mainOverflowY: main ? getComputedStyle(main).overflowY : '',
backLinkHref: backLink?.getAttribute('href') ?? '',
disabledNoticeHidden: Boolean(disabledNotice && disabledNotice.hidden),
sidebarText: sidebar?.textContent ?? '',
hasHeroSorc: Boolean(document.querySelector('.d2-stats__char-btn[data-char-name="HeroSorc"]')),
valNormal: document.getElementById('d2-stats-val-normal')?.textContent?.trim() ?? '',
valNightmare: document.getElementById('d2-stats-val-nightmare')?.textContent?.trim() ?? '',
valHell: document.getElementById('d2-stats-val-hell')?.textContent?.trim() ?? '',
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
andarielTotal: andarielRow?.querySelector('td[data-col="total"]')?.textContent?.trim() ?? '',
pindleskinTotal: pindleskinRow?.querySelector('td[data-col="total"]')?.textContent?.trim() ?? '',
fallenTotal: fallenRow?.querySelector('td[data-col="total"]')?.textContent?.trim() ?? '',
};
})()`)
assert(initialSnap.outerOverflow === 'hidden', `Expected .d2-stats overflow: hidden, got ${initialSnap.outerOverflow}`)
assert(initialSnap.sidebarOverflowY === 'auto', `Expected .d2-stats__sidebar overflow-y: auto, got ${initialSnap.sidebarOverflowY}`)
assert(initialSnap.mainOverflowY === 'auto', `Expected .d2-stats__main overflow-y: auto, got ${initialSnap.mainOverflowY}`)
assert(initialSnap.backLinkHref === '../../play.html', `Expected #d2-stats-back-link href="../../play.html", got ${initialSnap.backLinkHref}`)
assert(initialSnap.disabledNoticeHidden, 'Expected #d2-stats-disabled-notice to be hidden when kill-stats is enabled')
assert(initialSnap.sidebarText.includes('myacct'), `Sidebar must include account name myacct: ${initialSnap.sidebarText}`)
assert(initialSnap.hasHeroSorc, 'Sidebar must include character button for HeroSorc')
assert(initialSnap.valNormal === '0', `Expected Normal=0, got ${initialSnap.valNormal}`)
assert(initialSnap.valNightmare === '0', `Expected Nightmare=0, got ${initialSnap.valNightmare}`)
assert(initialSnap.valHell === '4', `Expected Hell=4, got ${initialSnap.valHell}`)
assert(initialSnap.valTotal === '4', `Expected Total=4, got ${initialSnap.valTotal}`)
assert(initialSnap.andarielTotal === '1', `Expected Andariel total=1, got ${initialSnap.andarielTotal}`)
assert(initialSnap.pindleskinTotal === '1', `Expected Pindleskin total=1, got ${initialSnap.pindleskinTotal}`)
assert(initialSnap.fallenTotal === '2', `Expected Fallen (hcIdx=19) total=2, got ${initialSnap.fallenTotal}`)
const shot1 = join(outDir, '01-stats-overview-zh.png')
const shot1Bytes = await statsCdp.captureScreenshot(shot1)
console.log(`[verify-kill-stats] Captured ${shot1} (${shot1Bytes} bytes) — Total=4 (Andariel=1, Pindleskin=1, Fallen=2)`)
// 5. Toggle Solo-Only filter -> expect Total = 3 (Fallen = 1)
await statsCdp.evaluate(`(() => {
const cb = document.getElementById('d2-stats-solo-only');
cb.click();
})()`)
const soloSnap = await statsCdp.evaluate<{ valTotal: string; fallenTotal: string }>(`(() => {
const fallenRow = document.querySelector('tr[data-monster-hcidx="19"]');
return {
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
fallenTotal: fallenRow?.querySelector('td[data-col="total"]')?.textContent?.trim() ?? '',
};
})()`)
assert(soloSnap.valTotal === '3', `Expected Solo-Only Total=3, got ${soloSnap.valTotal}`)
assert(soloSnap.fallenTotal === '1', `Expected Solo-Only Fallen=1, got ${soloSnap.fallenTotal}`)
const shot2 = join(outDir, '02-stats-solo-only.png')
const shot2Bytes = await statsCdp.captureScreenshot(shot2)
console.log(`[verify-kill-stats] Captured ${shot2} (${shot2Bytes} bytes) — Solo-Only Total=3`)
// Uncheck Solo-Only back to All Kills
await statsCdp.evaluate(`(() => {
const cb = document.getElementById('d2-stats-solo-only');
cb.click();
})()`)
// 6. Toggle Language (zh -> en -> zh)
await statsCdp.evaluate(`(() => {
const btn = document.getElementById('d2-stats-lang-toggle');
btn.click();
})()`)
const enSnap = await statsCdp.evaluate<{
titleText: string
andarielName: string
pindleskinName: string
}>(`(() => {
const andarielRow = document.querySelector('tr[data-canonical-id="andariel"]');
const pindleskinRow = document.querySelector('tr[data-canonical-id="Pindleskin"]');
return {
titleText: document.getElementById('d2-stats-page-title')?.textContent ?? '',
andarielName: andarielRow?.textContent ?? '',
pindleskinName: pindleskinRow?.textContent ?? '',
};
})()`)
assert(enSnap.titleText.includes('Kill Statistics'), `Expected English title, got ${enSnap.titleText}`)
assert(enSnap.andarielName.includes('Andariel'), `Expected English Andariel row, got ${enSnap.andarielName}`)
assert(enSnap.pindleskinName.includes('Pindleskin'), `Expected English Pindleskin row, got ${enSnap.pindleskinName}`)
const shot3 = join(outDir, '03-stats-overview-en.png')
const shot3Bytes = await statsCdp.captureScreenshot(shot3)
console.log(`[verify-kill-stats] Captured ${shot3} (${shot3Bytes} bytes) — English locale verified`)
// Toggle back to Chinese
await statsCdp.evaluate(`(() => {
const btn = document.getElementById('d2-stats-lang-toggle');
btn.click();
})()`)
// 7. Real-time cross-tab BroadcastChannel update: emit 1 more Fallen kill in playCdp + flushNow()
await playCdp.evaluate(`(async () => {
const emit = window.__d2EmitTestServerEvent;
emit({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: 105,
classId: 19,
x: 5050,
y: 5050,
mode: 1,
lifePct: 128,
},
});
emit({ type: 'UnitMode', unitType: 'monster', id: 105, mode: 12, x: 5050, y: 5050, lifePct: 0 });
await window.__d2PlayApp.plugins.getInstance('kill-stats').tracker.flushNow();
})()`)
let liveTotal = ''
for (let i = 0; i < 30; i++) {
await sleep(100)
liveTotal = await statsCdp.evaluate<string>(
`document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? ''`,
)
if (liveTotal === '5') break
}
assert(liveTotal === '5', `Expected live BroadcastChannel update to Total=5 without reload, got ${liveTotal}`)
const shot4 = join(outDir, '04-stats-live-broadcast-update.png')
const shot4Bytes = await statsCdp.captureScreenshot(shot4)
console.log(`[verify-kill-stats] Captured ${shot4} (${shot4Bytes} bytes) — Live BroadcastChannel auto-refresh Total=5`)
// 8. Export JSON & idempotent re-import via DOM.setFileInputFiles
await statsCdp.evaluate(`(() => {
const btn = document.getElementById('d2-stats-export-btn');
btn.click();
})()`)
let exportedJson = ''
for (let i = 0; i < 20; i++) {
await sleep(100)
exportedJson = await statsCdp.evaluate<string>(
`String(window.__d2LastExportedKillStatsJson ?? '')`,
)
if (exportedJson.length > 10) break
}
assert(exportedJson.includes('"format": "d2web-kill-stats"'), 'Exported JSON must contain d2web-kill-stats format')
const validExportPath = join(tmpImportDir, 'valid-kill-stats.json')
writeFileSync(validExportPath, exportedJson, 'utf8')
await statsCdp.setFileInputFiles('#d2-stats-import-input', [validExportPath])
await sleep(300)
const afterIdempotentImport = await statsCdp.evaluate<{
valTotal: string
infoVisible: boolean
infoText: string
errorVisible: boolean
}>(`(() => {
const info = document.getElementById('d2-stats-info-banner');
const err = document.getElementById('d2-stats-error-banner');
return {
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
infoVisible: Boolean(info && !info.hidden),
infoText: info?.textContent ?? '',
errorVisible: Boolean(err && !err.hidden),
};
})()`)
assert(
afterIdempotentImport.valTotal === '5',
`Idempotent re-import must keep Total=5, got ${afterIdempotentImport.valTotal}`,
)
assert(afterIdempotentImport.infoVisible, 'Info banner must be visible after valid import')
assert(!afterIdempotentImport.errorVisible, 'Error banner must be hidden after valid import')
console.log(`[verify-kill-stats] Idempotent JSON re-import verified: "${afterIdempotentImport.infoText}"`)
// 9. Tampered JSON import rejection (soloCount > count) -> [role="alert"] error banner
const tamperedObj = JSON.parse(exportedJson)
tamperedObj.tallies[0].soloCount = 999
const tamperedExportPath = join(tmpImportDir, 'tampered-kill-stats.json')
writeFileSync(tamperedExportPath, JSON.stringify(tamperedObj, null, 2), 'utf8')
await statsCdp.setFileInputFiles('#d2-stats-import-input', [tamperedExportPath])
await sleep(300)
const afterTamperedImport = await statsCdp.evaluate<{
errorVisible: boolean
errorRole: string
errorText: string
valTotal: string
}>(`(() => {
const err = document.getElementById('d2-stats-error-banner');
return {
errorVisible: Boolean(err && !err.hidden),
errorRole: err?.getAttribute('role') ?? '',
errorText: err?.textContent ?? '',
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
};
})()`)
assert(afterTamperedImport.errorVisible, 'Error banner must be visible on tampered JSON import')
assert(afterTamperedImport.errorRole === 'alert', `Expected role="alert", got ${afterTamperedImport.errorRole}`)
assert(
afterTamperedImport.errorText.includes('soloCount'),
`Expected error message to mention soloCount, got: ${afterTamperedImport.errorText}`,
)
assert(afterTamperedImport.valTotal === '5', 'Tampered import must not corrupt existing Total=5')
const shot5 = join(outDir, '05-stats-tampered-import-error.png')
const shot5Bytes = await statsCdp.captureScreenshot(shot5)
console.log(`[verify-kill-stats] Captured ${shot5} (${shot5Bytes} bytes) — Tampered JSON rejected with role="alert"`)
// 10. Select HeroSorc character and click Delete Character (auto-accepting confirm dialog)
await statsCdp.evaluate(`(() => {
const charBtn = document.querySelector('.d2-stats__char-btn[data-char-name="HeroSorc"]');
charBtn?.click();
})()`)
await sleep(150)
await statsCdp.evaluate(`(() => {
const delBtn = document.getElementById('d2-stats-delete-char-btn');
delBtn?.click();
})()`)
await sleep(400)
const afterDeleteSnap = await statsCdp.evaluate<{
valTotal: string
emptyVisible: boolean
hasHeroSorc: boolean
}>(`(() => {
const empty = document.getElementById('d2-stats-empty-state');
return {
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
emptyVisible: Boolean(empty && !empty.hidden),
hasHeroSorc: Boolean(document.querySelector('.d2-stats__char-btn[data-char-name="HeroSorc"]')),
};
})()`)
assert(afterDeleteSnap.valTotal === '0', `Expected Total=0 after deleting character, got ${afterDeleteSnap.valTotal}`)
assert(afterDeleteSnap.emptyVisible, 'Empty state must be visible after deleting character')
assert(!afterDeleteSnap.hasHeroSorc, 'HeroSorc button must be removed after deletion')
assert(statsCdp.dialogsOpened.length >= 1, 'Expected window.confirm dialog to be opened on delete')
const shot6 = join(outDir, '06-stats-deleted-empty-state.png')
const shot6Bytes = await statsCdp.captureScreenshot(shot6)
console.log(`[verify-kill-stats] Captured ${shot6} (${shot6Bytes} bytes) — Character deleted & empty state verified`)
// 11. Assert zero console errors and zero uncaught page exceptions across both tabs
assert(
playCdp.consoleErrors.length === 0,
`play.html had console errors: ${JSON.stringify(playCdp.consoleErrors)}`,
)
assert(
playCdp.pageExceptions.length === 0,
`play.html had page exceptions: ${JSON.stringify(playCdp.pageExceptions)}`,
)
assert(
statsCdp.consoleErrors.length === 0,
`stats.html had console errors: ${JSON.stringify(statsCdp.consoleErrors)}`,
)
assert(
statsCdp.pageExceptions.length === 0,
`stats.html had page exceptions: ${JSON.stringify(statsCdp.pageExceptions)}`,
)
console.log('[verify-kill-stats] ALL E2E BROWSER VERIFICATION CHECKS PASSED (0 console errors, 0 exceptions)')
} finally {
await statsCdp?.close()
await playCdp?.close()
if (chromeProc) {
chromeProc.kill('SIGKILL')
}
if (viteServer) {
await viteServer.close()
}
rmSync(userDataDir, { recursive: true, force: true })
rmSync(tmpImportDir, { recursive: true, force: true })
}
}
run().catch((err) => {
console.error('[verify-kill-stats] FAILED:', err)
process.exit(1)
})