Files
nl-game/electron/e2e.cjs
李琦 cd1ab90013 初始化
需要注意的是饥荒不够完善:动作、手持工具、细节交互、系统逻辑
2026-08-15 07:24:34 +08:00

234 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 端到端自检(开发用,不随应用打包),支持两种模式:
// 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 上偶发 UnknownVizErrorGPU 合成器抖动),重试且失败不阻断流程
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)