795 lines
32 KiB
TypeScript
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)
|
|
})
|