// 端到端自检(开发用,不随应用打包),支持两种模式: // desktop(默认):以 file:// 加载 dist 构建产物 + preload 注入,验证桌面端(hash 路由 + 配置服务器地址) // web:不带 preload 加载 Vite 开发服务器,等价纯浏览器环境,验证网页版(history 路由 + 同源代理) // 流程:清空登录态 → 演示账号真实登录 → 大厅(含购买弹窗)→ 对战大厅 → WS 建房/退房 → 单机开一局 → 汇总页面报错 // 运行:npm run electron:e2e(桌面模式);$env:NLG_E2E_MODE='web'; npx electron electron/e2e.cjs(网页模式,需 5173 开发服务器) // NLG_E2E_GAME 可指定单机步骤玩哪款游戏(默认 snake,如 mario / linkgame) const { app, BrowserWindow, ipcMain } = require('electron') const path = require('path') const fs = require('fs') const MODE = process.env.NLG_E2E_MODE === 'web' ? 'web' : 'desktop' const SERVER = process.env.NLG_SERVER || 'http://127.0.0.1:8080' const WEB_URL = process.env.NLG_E2E_URL || 'http://localhost:5173' const OUT_DIR = process.env.NLG_E2E_OUT || process.cwd() const GAME = process.env.NLG_E2E_GAME || 'snake' const SUFFIX = MODE === 'web' ? '-web' : '' const pageErrors = [] // preload 依赖该同步 IPC 提供服务器地址(仅桌面模式挂 preload) ipcMain.on('nlg:get-server-base', (e) => { e.returnValue = SERVER }) ipcMain.handle('nlg:set-server-base', () => ({ ok: true })) const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) async function main() { const win = new BrowserWindow({ width: 1280, height: 820, show: true, backgroundColor: '#0f1220', webPreferences: MODE === 'desktop' ? { preload: path.join(__dirname, 'preload.cjs') } : {}, }) // 收集页面报错(Electron 新版事件对象:event.level 为 'error' 等字符串) win.webContents.on('console-message', (e) => { if (e?.level === 'error' && e?.message) pageErrors.push(e.message) }) const js = (code) => win.webContents.executeJavaScript(code, true) // 截图:capturePage 在 Windows 上偶发 UnknownVizError(GPU 合成器抖动),重试且失败不阻断流程 const shot = async (name) => { for (let i = 0; i < 3; i++) { try { const img = await win.webContents.capturePage() const file = path.join(OUT_DIR, `${name}${SUFFIX}.png`) fs.writeFileSync(file, img.toPNG()) console.log('[e2e] screenshot:', file) return } catch (err) { if (i === 2) console.warn(`[e2e] 截图 ${name} 失败(不影响流程):${err.message}`) else await sleep(600) } } } // 轮询等待页面条件成立 const waitFor = async (cond, timeoutMs, desc) => { const t0 = Date.now() while (Date.now() - t0 < timeoutMs) { try { if (await js(cond)) return true } catch {} await sleep(300) } throw new Error(`等待超时:${desc}`) } // 路由条件与跳转:桌面端 hash 模式,网页版 history 模式 const atRoute = (p) => (MODE === 'desktop' ? `location.hash === '#${p}'` : `location.pathname === '${p}'`) const goto = async (p) => { if (MODE === 'desktop') { await js(`location.hash = '#${p}'; true`) } else { // history 模式下直接整页跳转(等价用户输入地址),开发服务器有 SPA 回退 await win.loadURL(`${WEB_URL}${p}`) } await waitFor(atRoute(p), 5000, `路由到 ${p}`) } if (MODE === 'desktop') { const distIndex = path.join(__dirname, '../dist/index.html') if (!fs.existsSync(distIndex)) throw new Error('缺少 dist/index.html,请先 npm run electron:dist') await win.loadFile(distIndex) } else { await win.loadURL(WEB_URL).catch(() => { throw new Error(`无法访问 ${WEB_URL},请先 npm run dev 启动开发服务器`) }) } // 第 0 步:清空历史登录态,确保从登录页开始 await js('localStorage.clear(); true') await win.webContents.reload() await waitFor(`!!document.querySelector('input[placeholder^="用户名"]')`, 10000, '登录页渲染') console.log(`[e2e] 登录页已渲染(${MODE === 'desktop' ? 'file:// + hash 路由' : 'http + history 路由'})`) // 第 1 步:填写演示账号并提交(触发 input 事件以驱动 v-model) await js(` (() => { const u = document.querySelector('input[placeholder^="用户名"]') u.value = 'xiaoming'; u.dispatchEvent(new Event('input', { bubbles: true })) const p = document.querySelector('input[type="password"]') p.value = '123456'; p.dispatchEvent(new Event('input', { bubbles: true })) document.querySelector('button.submit').click() return true })() `) // 第 2 步:等待登录成功(Token 落库 + 路由跳到大厅) await waitFor(`!!localStorage.getItem('token') && ${atRoute('/')}`, 10000, '登录成功并跳转大厅') console.log('[e2e] 登录成功,Token 已写入,已跳转大厅') await sleep(1800) // 等游戏列表与字体渲染 await shot('electron-smoke-lobby') // 第 2.5 步:大厅购买弹窗(若存在待解锁游戏则点开验证展示与关闭) const hasBuyBtn = await js( `!![...document.querySelectorAll('.game-card button')].find((b) => b.textContent.includes('去购买'))` ) if (hasBuyBtn) { await js(` [...document.querySelectorAll('.game-card button')].find((b) => b.textContent.includes('去购买')).click(); true `) await waitFor(`!!document.querySelector('.buy-modal')`, 4000, '购买弹窗打开') await sleep(400) await shot('electron-smoke-buymodal') await js(`document.querySelector('.buy-modal .bm-close').click(); true`) await waitFor(`!document.querySelector('.buy-modal')`, 3000, '购买弹窗关闭') console.log('[e2e] 大厅购买弹窗:打开展示详情 / 关闭均正常') } else { console.log('[e2e] 大厅无待解锁游戏,跳过购买弹窗检查') } // 第 2.6 步:确保目标游戏可玩——未拥有则通过弹窗「立即购买」走一遍真实购买闭环 const gameName = await js(` fetch((window.desktop && window.desktop.serverBase ? window.desktop.serverBase : '') + '/api/games', { headers: { Authorization: 'Bearer ' + localStorage.getItem('token') }, }).then((r) => r.json()).then((j) => ((j.data || []).find((g) => g.code === '${GAME}') || {}).name || '') `) if (!gameName) throw new Error(`游戏 ${GAME} 不存在或列表接口异常`) const cardBtnLabel = `(() => { const card = [...document.querySelectorAll('.game-card')].find((c) => (c.querySelector('.name')?.textContent || '').trim() === ${JSON.stringify(gameName)}) const btn = card && card.querySelector('.actions button:last-child') return btn ? btn.textContent.trim() : '' })()` if ((await js(cardBtnLabel)) === '去购买') { await js(`(() => { const card = [...document.querySelectorAll('.game-card')].find((c) => (c.querySelector('.name')?.textContent || '').trim() === ${JSON.stringify(gameName)}) card.querySelector('.actions button:last-child').click() return true })()`) await waitFor(`!!document.querySelector('.buy-modal')`, 4000, '目标游戏购买弹窗打开') const canBuy = await js(`!document.querySelector('.buy-modal .bm-actions .btn:last-child').disabled`) if (!canBuy) throw new Error(`积分不足,无法购买 ${gameName},无法继续单机游戏步骤`) await js(`document.querySelector('.buy-modal .bm-actions .btn:last-child').click(); true`) await waitFor(`!document.querySelector('.buy-modal')`, 6000, '购买完成弹窗自动关闭') await waitFor(`${cardBtnLabel} === '开始游戏'`, 6000, '购买后卡片刷新为可玩') console.log(`[e2e] 已通过大厅弹窗直接购买「${gameName}」,卡片已解锁`) } else { console.log(`[e2e] 「${gameName}」已可玩,无需购买`) } // 第 3 步:进入对战大厅 await goto('/battle') await sleep(1500) await shot('electron-smoke-battle') // 第 4 步:WebSocket 建房(联机核心链路)——创建斗地主 AI 房间并拿到邀请码 await js(` (() => { const btn = [...document.querySelectorAll('button')].find((b) => b.textContent.includes('创建房间')) btn.click() return true })() `) await waitFor( `${atRoute('/battle/room')} && /^[A-Z0-9]{6}$/.test(document.querySelector('.code-box b')?.textContent?.trim() || '')`, 10000, 'WS 建房成功并显示 6 位邀请码' ) const roomCode = await js(`document.querySelector('.code-box b').textContent.trim()`) console.log('[e2e] WebSocket 建房成功,邀请码:' + roomCode) await sleep(900) await shot('electron-smoke-room') // 退出房间,回到对战大厅 await js(` (() => { const btn = [...document.querySelectorAll('button')].find((b) => b.textContent.includes('退出房间')) btn.click() return true })() `) await waitFor(atRoute('/battle'), 5000, '退房返回对战大厅') console.log('[e2e] 已退出房间') // 第 5 步:单机游戏运行(渲染与游戏循环)——默认贪吃蛇,可用 NLG_E2E_GAME 换游戏 // 普通游戏是「开始游戏」按钮;带关卡的游戏(连连看/消消乐/马里奥)是关卡选择面板的继续按钮 await goto(`/play/${GAME}`) const startBtnExpr = `(() => { return document.querySelector('.overlay .ls-continue') || [...document.querySelectorAll('.overlay button')].find((b) => b.textContent.includes('开始游戏')) || null })()` await waitFor(`!!${startBtnExpr}`, 10000, `${GAME} 游玩页就绪`) const isLevelGame = await js(`!!document.querySelector('.overlay .level-select')`) if (isLevelGame) { console.log(`[e2e] ${GAME} 为关卡制游戏,关卡选择面板已展示`) } await js(`(${startBtnExpr}).click(); true`) await sleep(2200) // 让游戏跑几帧 // Canvas 游戏看画布,DOM 游戏(连连看/消消乐)看棋盘容器 await waitFor( `!!document.querySelector('.stage canvas') || !!document.querySelector('.stage .link-board') || !!document.querySelector('.stage .m3-board .board')`, 5000, '游戏画面渲染' ) console.log(`[e2e] ${GAME} 已开局,画面渲染正常`) await shot('electron-smoke-game') // 汇总 if (pageErrors.length) { console.error(`[e2e] 页面报错 ${pageErrors.length} 条:`) for (const m of pageErrors.slice(0, 10)) console.error(' -', m) app.exit(1) return } console.log(`[e2e] 全部通过(${MODE} 模式):登录 / 路由 / 大厅 / WS 建房退房 / 单机游戏均正常,无页面报错`) app.exit(0) } app.whenReady().then(() => main().catch((err) => { console.error('[e2e] 失败:', err.message) app.exit(1) }) ) // 兜底超时 setTimeout(() => { console.error('[e2e] 总超时') app.exit(1) }, 90000)