Files
nl-game/electron/e2e.cjs

234 lines
11 KiB
JavaScript
Raw Normal View History

// 端到端自检(开发用,不随应用打包),支持两种模式:
// 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)