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