Files
LoveLiveMusicPlayer-Next/scripts/monkey-test.mjs
2026-08-08 18:34:12 +08:00

897 lines
27 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* LoveLiveMusicPlayer-Next · 复杂 Monkey 测试
*
* 层级:
* A. 协议 / 数据模型 fuzz
* B. 渲染进程 IPC 压力(经 Playwright evaluate
* C. 场景流(歌单/搜索/传歌/设置/歌词)
* D. 混沌乱点侧栏、hash 风暴、连点、滚轮、键盘)
*
* 用法:
* node scripts/monkey-test.mjs --actions=200 --seed=42 --complex
* npm run monkey:build -- --actions=200 --complex
*/
import { createRequire } from 'module'
import { spawn } from 'child_process'
import { fileURLToPath } from 'url'
import path from 'path'
import fs from 'fs'
import { createHash } from 'crypto'
const require = createRequire(import.meta.url)
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const args = Object.fromEntries(
process.argv.slice(2).map((a) => {
const m = a.match(/^--([^=]+)=(.*)$/)
return m ? [m[1], m[2]] : [a.replace(/^--/, ''), true]
})
)
const COMPLEX = args.complex !== false && args.simple !== true
const ACTIONS = Number(args.actions || (COMPLEX ? 200 : 80))
const SEED = Number(args.seed || Date.now() % 1e9)
const STORM = Number(args.storm || (COMPLEX ? 40 : 10))
function mulberry32(a) {
return function () {
let t = (a += 0x6d2b79f5)
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const rand = mulberry32(SEED)
const pick = (arr) => arr[Math.floor(rand() * arr.length)]
const chance = (p) => rand() < p
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const report = {
startedAt: new Date().toISOString(),
seed: SEED,
actions: ACTIONS,
complex: COMPLEX,
storm: STORM,
logic: { passed: 0, failed: 0, cases: [] },
ipc: { calls: 0, errors: [], samples: [] },
scenarios: { ran: [], failed: [] },
ui: {
launched: false,
actionsDone: 0,
consoleErrors: [],
pageErrors: [],
crashes: [],
navigation: [],
dialogs: { accepted: 0, dismissed: 0 },
notes: []
},
verdict: 'UNKNOWN'
}
function logicCase(name, fn) {
try {
fn()
report.logic.passed++
report.logic.cases.push({ name, ok: true })
} catch (e) {
report.logic.failed++
report.logic.cases.push({ name, ok: false, error: String(e?.message || e).slice(0, 300) })
}
}
async function scenario(name, fn) {
try {
await fn()
report.scenarios.ran.push(name)
} catch (e) {
report.scenarios.failed.push({ name, error: String(e?.message || e).slice(0, 300) })
}
}
/* ======================== A. 协议 / 模型 ======================== */
const DOWNLOAD_BODY_SEPARATOR = ' === '
const encodeFtpCmd = (cmd, body = '') => JSON.stringify({ cmd, body: String(body) })
const decodeFtpCmd = (raw) => {
const obj = JSON.parse(raw)
return { cmd: String(obj.cmd), body: obj.body == null ? '' : String(obj.body) }
}
function parseTransData(body) {
const raw = JSON.parse(body)
const love = (raw.love || [])
.map((o) => ({
musicId: String(o.musicId ?? o.musicUId ?? ''),
timestamp: Number(o.timestamp ?? o.createTime ?? Date.now()) || Date.now()
}))
.filter((l) => l.musicId)
const menu = (raw.menu || [])
.map((o) => {
let list = o.musicList ?? o.musicUIds ?? o.music ?? []
if (typeof list === 'string') {
try {
list = JSON.parse(list)
} catch {
list = [list]
}
}
return {
menuId: Number(o.menuId ?? o.id ?? 0),
name: String(o.name ?? o.title ?? ''),
date: String(o.date ?? ''),
musicList: (Array.isArray(list) ? list : []).map(String).filter(Boolean)
}
})
.filter((m) => Number.isFinite(m.menuId) && m.menuId > 0)
return { love, menu, isCover: !!raw.isCover }
}
function mediaAbsPath(rootDir, baseUrl, musicPath) {
const parts = [rootDir, baseUrl, musicPath]
.map((p) => String(p).replace(/[/\\]+/g, '/').replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
const joined = parts.join('/')
return rootDir.includes('\\') ? joined.replace(/\//g, '\\') : joined
}
function runLogicMonkey() {
logicCase('encode/decode roundtrip', () => {
const d = decodeFtpCmd(encodeFtpCmd('prepare', '[{"a":1}] === false'))
if (d.cmd !== 'prepare' || !d.body.includes(' === ')) throw new Error('mismatch')
})
logicCase('unicode + emoji body', () => {
const body = JSON.stringify([{ musicName: '幻影ノメゾン🎤', path: 'a/b [c].flac' }])
JSON.parse(decodeFtpCmd(encodeFtpCmd('ready', body)).body)
})
logicCase('prepare 3000 tracks', () => {
const list = Array.from({ length: 3000 }, (_, i) => ({
albumUId: `a${i}`,
musicUId: `m${i}`,
musicName: `${i}`,
musicPath: `${i}.flac`,
baseUrl: 'LoveLive/test/',
coverPath: 'c.jpg',
albumId: i % 50,
albumName: 'A',
date: '2020-01-01',
category: 'single',
group: "μ's",
musicId: i,
artist: 'x',
artistBin: '1e7',
totalTime: '03:00',
existFile: true
}))
const body = `${JSON.stringify(list)}${DOWNLOAD_BODY_SEPARATOR}true`
const [json, flag] = decodeFtpCmd(encodeFtpCmd('prepare', body)).body.split(
DOWNLOAD_BODY_SEPARATOR
)
if (JSON.parse(json).length !== 3000 || flag !== 'true') throw new Error('prepare broken')
})
logicCase('download body separator edge', () => {
const uid = 'abc === def'
const raw = `${uid}${DOWNLOAD_BODY_SEPARATOR}false`
const parts = raw.split(DOWNLOAD_BODY_SEPARATOR)
// 协议本身对 uid 含分隔符不健壮——记录行为即可
if (parts.length < 2) throw new Error('split failed')
})
logicCase('transData phone wire format', () => {
const wire = JSON.stringify({
love: [{ musicId: 'm1', timestamp: 1 }],
menu: [{ menuId: 101, name: '手机歌单', date: '2024-01-01', musicList: ['m1', 'm2'] }],
isCover: false
})
const d = parseTransData(wire)
if (d.menu[0].menuId !== 101 || d.love[0].musicId !== 'm1') throw new Error('parse')
})
logicCase('transData legacy field aliases', () => {
const wire = JSON.stringify({
love: [{ musicUId: 'x', createTime: 9 }],
menu: [{ id: 102, title: 'T', musicUIds: ['a'] }],
isCover: true
})
const d = parseTransData(wire)
if (d.love[0].musicId !== 'x' || d.menu[0].name !== 'T') throw new Error('alias')
})
logicCase('transData musicList as JSON string', () => {
const wire = JSON.stringify({
love: [],
menu: [{ menuId: 103, name: 'S', date: '', musicList: '["u1","u2"]' }],
isCover: false
})
const d = parseTransData(wire)
if (d.menu[0].musicList.length !== 2) throw new Error('string list')
})
logicCase('menu id boundaries', () => {
const ids = [1, 100, 101, 200, 0, -1, NaN, 1.5]
const kept = ids.map(Number).filter((n) => Number.isFinite(n) && n > 0)
if (!kept.includes(101) || kept.includes(0)) throw new Error('boundary')
})
logicCase('path join windows/posix', () => {
const a = mediaAbsPath('E:\\Music', "LoveLive/μ's/", '01. song.flac')
const b = mediaAbsPath('/home/m', 'LoveLive/x/', 'a.flac')
// 实现会裁掉首尾分隔符posix 根可能变成 home/m/...
if (!a.includes('01. song.flac') || !String(a).includes('Music')) throw new Error(`win:${a}`)
if (!b.includes('a.flac') || !b.includes('LoveLive')) throw new Error(`posix:${b}`)
})
logicCase('malformed raw rejected', () => {
let threw = false
try {
decodeFtpCmd('{bad')
} catch {
threw = true
}
if (!threw) throw new Error('expected throw')
})
// 大批量随机 fuzz
const cmds = [
'version',
'prepare',
'ready',
'download',
'phone2pc',
'pc2phone',
'system',
'port',
'',
'💥',
'download success'
]
for (let i = 0; i < (COMPLEX ? 80 : 40); i++) {
logicCase(`fuzz#${i}`, () => {
const cmd = pick(cmds)
const body = pick([
'',
'1',
'true',
'[]',
'{}',
'null',
'a === true',
'm1 === false === extra',
JSON.stringify({
love: Array.from({ length: Math.floor(rand() * 5) }, (_, j) => ({
musicId: `m${j}`,
timestamp: j
})),
menu: Array.from({ length: Math.floor(rand() * 3) }, (_, j) => ({
menuId: 101 + j,
name: `歌单${j}`,
date: '',
musicList: [`m${j}`]
})),
isCover: chance(0.5)
}),
'\u0000\u0001',
'中文'.repeat(Math.floor(rand() * 30)),
'<script>alert(1)</script>',
"' OR 1=1 --",
'../'.repeat(20) + 'etc/passwd'
])
const d = decodeFtpCmd(encodeFtpCmd(cmd, body))
if (typeof d.cmd !== 'string' || typeof d.body !== 'string') throw new Error('type')
if (cmd === 'phone2pc' || cmd === 'pc2phone') {
try {
parseTransData(d.body)
} catch {
/* 畸形可解析失败 */
}
}
})
}
// 哈希稳定性 / 路由怪值
logicCase('hostile routes hash', () => {
for (const r of [
'/',
'/playlists',
'/../../../etc/passwd',
'/playlists?x=<script>',
'/' + 'a'.repeat(8000),
'/albums/' + encodeURIComponent("μ's"),
'/lyric#frag'
]) {
createHash('sha1').update(r).digest('hex')
}
})
}
/* ======================== Build ======================== */
async function ensureBuild() {
const mainJs = path.join(root, 'out', 'main', 'index.js')
if (fs.existsSync(mainJs) && !args.rebuild) {
report.ui.notes.push('reuse existing out/ build')
return
}
report.ui.notes.push('running electron-vite build…')
await new Promise((resolve, reject) => {
const p = spawn('npx', ['electron-vite', 'build'], {
cwd: root,
shell: true,
stdio: 'inherit'
})
p.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`build exit ${code}`))))
})
}
/* ======================== UI helpers ======================== */
async function gotoHash(win, route) {
await win.evaluate((r) => {
window.location.hash = `#${r}`
}, route)
await sleep(180)
report.ui.navigation.push(`hash:${route}`)
}
async function clickText(win, label) {
const el = win.locator(`text=${label}`).first()
if ((await el.count()) === 0) return false
await el.click({ timeout: 2500 })
report.ui.navigation.push(label)
return true
}
async function randomClickables(win, n = 1) {
for (let i = 0; i < n; i++) {
const label = await win.evaluate(() => {
const nodes = [
...document.querySelectorAll(
'button, a, [role="button"], input[type="checkbox"], label, .sidebar-item'
)
].filter((el) => {
const s = getComputedStyle(el)
const r = el.getBoundingClientRect()
return (
s.visibility !== 'hidden' &&
s.display !== 'none' &&
!el.disabled &&
r.width > 2 &&
r.height > 2
)
})
if (!nodes.length) return null
const el = nodes[Math.floor(Math.random() * nodes.length)]
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }))
return (el.innerText || el.getAttribute('aria-label') || el.tagName || '').slice(0, 48)
})
if (label) report.ui.navigation.push(`click:${label}`)
}
}
/* ======================== B. IPC 压力 ======================== */
async function runIpcMonkey(win) {
const results = await win.evaluate(async () => {
const out = { calls: 0, errors: [], samples: [] }
const api = window.api
if (!api) {
out.errors.push('window.api missing')
return out
}
const safe = async (name, fn) => {
out.calls++
try {
const r = await fn()
out.samples.push({ name, ok: true, preview: JSON.stringify(r)?.slice(0, 80) })
} catch (e) {
out.errors.push(`${name}: ${String(e).slice(0, 120)}`)
out.samples.push({ name, ok: false })
}
}
// 正常读
await safe('store.get volume', () => api.store.get('volume'))
await safe('http.status', () => api.http.status())
await safe('net.lanIps', () => api.net.lanIps())
await safe('library.dataVersion', () => api.library.dataVersion())
await safe('db.query album', () => api.db.query('SELECT COUNT(*) as c FROM album'))
await safe('db.query music', () => api.db.query('SELECT COUNT(*) as c FROM music'))
await safe('db.query menu', () => api.db.query('SELECT * FROM menu ORDER BY id DESC LIMIT 20'))
// 写读往返monkey userData
const key = `__monkey_${Date.now()}`
await safe('store.set', () => api.store.set(key, { n: 1, s: '测' }))
await safe('store.get', () => api.store.get(key))
await safe('store.delete', () => api.store.delete(key))
// 歌单 CRUD 压力
const mid = 150 + Math.floor(Math.random() * 40)
await safe('menu upsert', () =>
api.db.exec('INSERT OR REPLACE INTO menu (id, title, cover, createTime) VALUES (?,?,?,?)', [
mid,
`Monkey歌单${mid}`,
null,
Date.now()
])
)
await safe('playlist link', () =>
api.db.exec(
'INSERT OR IGNORE INTO playlist_music (menuId, musicUId, "order") VALUES (?,?,?)',
[mid, 'nonexistent-uid-monkey', 0]
)
)
await safe('menu delete', async () => {
await api.db.exec('DELETE FROM playlist_music WHERE menuId = ?', [mid])
await api.db.exec('DELETE FROM menu WHERE id = ?', [mid])
})
// 并发风暴
const storm = []
for (let i = 0; i < 30; i++) {
storm.push(api.db.query('SELECT musicUId FROM music LIMIT 5'))
storm.push(api.store.get('volume'))
storm.push(api.http.status())
}
out.calls += storm.length
try {
await Promise.all(storm)
out.samples.push({ name: 'concurrent x90', ok: true })
} catch (e) {
out.errors.push(`concurrent: ${String(e).slice(0, 120)}`)
}
// 故意坏 SQL期望抛错且进程不崩
out.calls++
try {
await api.db.query('SELECT * FROM not_a_table_monkey')
out.errors.push('bad sql: expected throw')
} catch {
out.samples.push({ name: 'bad sql expected fail', ok: true })
}
// 传歌/同步服务启停连打
await safe('musicServer.start', () => api.musicServer.start())
await safe('musicServer.send offline', () => api.musicServer.send('prepare', '[] === false'))
await safe('musicServer.stop', () => api.musicServer.stop())
await safe('dataServer.start', () => api.dataServer.start())
await safe('dataServer.send offline', () =>
api.dataServer.send('phone2pc', JSON.stringify({ love: [], menu: [], isCover: false }))
)
await safe('dataServer.stop', () => api.dataServer.stop())
// 快速启停
for (let i = 0; i < 5; i++) {
await safe(`music bounce ${i}`, async () => {
await api.musicServer.start()
await api.musicServer.stop()
})
}
return out
})
report.ipc.calls += results.calls || 0
report.ipc.errors.push(...(results.errors || []))
report.ipc.samples = (results.samples || []).slice(0, 40)
}
/* ======================== C. 场景流 ======================== */
async function runScenarios(win) {
await scenario('home groups + play attempt', async () => {
await gotoHash(win, '/')
await sleep(300)
for (const g of ["μ's", 'Aqours', 'Liella!', '全部']) {
await clickText(win, g).catch(() => false)
await sleep(80)
}
await randomClickables(win, 3)
})
await scenario('search hostile queries', async () => {
await gotoHash(win, '/search')
const box = win.locator('input[placeholder*="搜索"]').first()
if ((await box.count()) === 0) return
for (const q of [
'μ',
'Aqours',
'zzz_not_exist_9x',
'<script>',
"' OR 1=1",
' ',
'❤️',
'a'.repeat(200)
]) {
await box.fill(q)
await sleep(120)
}
await randomClickables(win, 2)
await box.fill('')
})
await scenario('playlist create cancel escape', async () => {
await gotoHash(win, '/playlists')
await sleep(250)
const created = await clickText(win, '新建')
if (created) {
const input = win.locator('input[placeholder*="歌单"]').first()
if (await input.count()) {
await input.fill(`Monkey-${SEED}`)
await win.keyboard.press('Escape')
await sleep(100)
// 再开一次并确认
await clickText(win, '新建')
if (await input.count()) {
await input.fill(`Monkey-OK-${SEED % 1000}`)
await win.keyboard.press('Enter')
await sleep(200)
}
}
}
await randomClickables(win, 2)
})
await scenario('favorites / history churn', async () => {
await gotoHash(win, '/favorites')
await sleep(200)
await randomClickables(win, 2)
await gotoHash(win, '/history')
await sleep(200)
await randomClickables(win, 2)
})
await scenario('transfer checkbox spam', async () => {
await gotoHash(win, '/transfer')
await sleep(400)
// 勾选框连点
await win.evaluate(() => {
document.querySelectorAll('input[type="checkbox"]').forEach((el, i) => {
if (i < 25) el.click()
})
})
await clickText(win, '全选').catch(() => false)
await clickText(win, '仅推荐').catch(() => false)
await sleep(150)
await randomClickables(win, 2)
})
await scenario('sync page connect UI', async () => {
await gotoHash(win, '/sync')
await sleep(400)
await randomClickables(win, 2)
})
await scenario('settings source toggles', async () => {
await gotoHash(win, '/settings')
await sleep(300)
await clickText(win, '本地').catch(() => false)
await clickText(win, '远程').catch(() => false)
await clickText(win, '本地').catch(() => false)
const remote = win.locator('input[placeholder*="http"]').first()
if (await remote.count()) {
await remote.fill('http://127.0.0.1:9/')
await sleep(50)
await remote.fill('')
}
await randomClickables(win, 3)
})
await scenario('lyric overlay open/close', async () => {
await gotoHash(win, '/')
await sleep(200)
// 尝试点播放条区域打开歌词(若有封面/标题)
await win.evaluate(() => {
const bar = document.querySelector('footer, [class*="Player"], [class*="player"]')
if (bar) bar.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await gotoHash(win, '/lyric')
await sleep(300)
await win.keyboard.press('Escape')
await sleep(100)
await gotoHash(win, '/')
})
await scenario('hash storm', async () => {
const routes = [
'/',
'/playlists',
'/favorites',
'/history',
'/transfer',
'/sync',
'/settings',
'/search',
'/albums/not-exist-id'
]
for (let i = 0; i < STORM; i++) {
await gotoHash(win, pick(routes))
}
})
await scenario('server start storm via pages', async () => {
for (let i = 0; i < 6; i++) {
await gotoHash(win, chance(0.5) ? '/transfer' : '/sync')
await sleep(120)
await gotoHash(win, '/')
await sleep(80)
}
})
}
/* ======================== D. 混沌循环 ======================== */
async function runChaosLoop(win) {
const sidebar = ['音乐馆', '我喜欢', '歌单', '最近播放', '传歌', '数据同步', '设置']
const routes = [
'/',
'/playlists',
'/favorites',
'/history',
'/transfer',
'/sync',
'/settings',
'/search'
]
for (let i = 0; i < ACTIONS; i++) {
if (!win) break
const kind = pick([
'sidebar',
'hash',
'click',
'dblclick',
'scroll',
'keys',
'search',
'wheel',
'resize',
'burst'
])
try {
if (kind === 'sidebar') {
await clickText(win, pick(sidebar))
} else if (kind === 'hash') {
await gotoHash(win, pick(routes))
} else if (kind === 'click') {
await randomClickables(win, 1)
} else if (kind === 'dblclick') {
await win.evaluate(() => {
const nodes = [...document.querySelectorAll('button, img, .apple-card')].filter((el) => {
const r = el.getBoundingClientRect()
return r.width > 8 && r.height > 8
})
if (!nodes.length) return
const el = nodes[Math.floor(Math.random() * nodes.length)]
el.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window })
)
})
} else if (kind === 'scroll') {
await win.evaluate(() => {
const el =
document.querySelector('[class*="overflow"]') ||
document.scrollingElement ||
document.body
el.scrollBy(0, (Math.random() - 0.5) * 800)
})
} else if (kind === 'wheel') {
await win.mouse.wheel(0, (rand() - 0.5) * 1200)
} else if (kind === 'keys') {
const combo = pick([
['Escape'],
['Tab'],
['ArrowDown'],
['ArrowUp'],
['Enter'],
['Space'],
['Control', 'a'],
['F5']
])
for (const k of combo) await win.keyboard.down(k)
for (const k of [...combo].reverse()) await win.keyboard.up(k)
} else if (kind === 'search') {
const box = win.locator('input[placeholder*="搜索"]').first()
if (await box.count()) {
await box.fill(pick(['μ', 'Liella', 'xxx', '!', ' ']))
}
} else if (kind === 'resize') {
const [w, h] = pick([
[1377, 887],
[1400, 900],
[1600, 1000],
[1377, 887]
])
await win.setViewportSize({ width: w, height: h }).catch(() => {})
} else if (kind === 'burst') {
await randomClickables(win, 5)
await gotoHash(win, pick(routes))
}
report.ui.actionsDone++
} catch (e) {
report.ui.pageErrors.push(`chaos#${i} ${kind}: ${String(e).slice(0, 180)}`)
}
if (i % 15 === 14) await sleep(80)
}
}
/* ======================== Launch ======================== */
async function runUiMonkey() {
const { _electron: electron } = await import('playwright')
const electronPath = require('electron')
let app
try {
app = await electron.launch({
executablePath: electronPath,
args: [root],
cwd: root,
env: {
...process.env,
ELECTRON_DISABLE_SECURITY_WARNINGS: 'true',
LLMP_MONKEY: '1'
},
timeout: 90000
})
} catch (e) {
report.ui.crashes.push(`launch failed: ${e}`)
return
}
report.ui.launched = true
await sleep(1500)
const win = await app.firstWindow({ timeout: 60000 }).catch((e) => {
report.ui.crashes.push(`firstWindow: ${e}`)
return null
})
if (!win) {
await app.close().catch(() => {})
return
}
// 原生 confirm / alert
win.on('dialog', async (dialog) => {
try {
if (chance(0.55)) {
await dialog.accept()
report.ui.dialogs.accepted++
} else {
await dialog.dismiss()
report.ui.dialogs.dismissed++
}
} catch {
/* ignore */
}
})
win.on('console', (msg) => {
if (msg.type() === 'error') report.ui.consoleErrors.push(msg.text())
})
win.on('pageerror', (err) => {
report.ui.pageErrors.push(String(err))
})
await sleep(2200)
try {
await win.waitForSelector('text=LoveLiveMusicPlayer', { timeout: 20000 })
} catch {
report.ui.notes.push('sidebar title slow; continue')
}
// 顺序IPC → 场景 → 混沌
if (COMPLEX) {
await runIpcMonkey(win).catch((e) => report.ipc.errors.push(String(e)))
await runScenarios(win)
}
await runChaosLoop(win)
// 终局健康检查
try {
const health = await win.evaluate(async () => {
const api = window.api
return {
hasApi: !!api,
path: location.hash,
albumCount: api
? (await api.db.query('SELECT COUNT(*) as c FROM album'))[0]?.c
: null,
musicServerPing: api ? await api.http.status() : null
}
})
report.ui.notes.push(`health: ${JSON.stringify(health)}`)
} catch (e) {
report.ui.crashes.push(`health check: ${e}`)
}
try {
const alive = !!app.process() && !app.process().killed
if (!alive) report.ui.crashes.push('process dead after monkey')
await app.close()
} catch (e) {
report.ui.crashes.push(`close: ${e}`)
}
}
function finalize() {
report.ui.consoleErrors = [...new Set(report.ui.consoleErrors)].slice(0, 50)
report.ui.pageErrors = [...new Set(report.ui.pageErrors)].slice(0, 50)
report.ipc.errors = [...new Set(report.ipc.errors)].slice(0, 40)
const hardUi =
report.ui.crashes.length +
report.ui.pageErrors.filter((e) => !/Timeout|waiting for|Target closed/i.test(e)).length
const softIpc = report.ipc.errors.filter((e) => !/bad sql|no such table|not_a_table/i.test(e))
const scenarioFails = report.scenarios.failed.length
if (
report.logic.failed === 0 &&
report.ui.launched &&
hardUi === 0 &&
softIpc.length === 0 &&
scenarioFails === 0 &&
report.ui.actionsDone > 0
) {
report.verdict = 'PASS'
} else if (
report.logic.failed === 0 &&
report.ui.launched &&
hardUi === 0 &&
report.ui.actionsDone > 0
) {
report.verdict = 'PASS_WITH_WARNINGS'
} else if (!report.ui.launched && report.logic.failed === 0) {
report.verdict = 'LOGIC_PASS_UI_SKIP'
} else {
report.verdict = 'FAIL'
}
const outDir = path.join(root, 'monkey-results')
fs.mkdirSync(outDir, { recursive: true })
const outFile = path.join(outDir, `monkey-complex-${Date.now()}.json`)
fs.writeFileSync(outFile, JSON.stringify(report, null, 2), 'utf8')
fs.writeFileSync(path.join(outDir, 'latest.json'), JSON.stringify(report, null, 2), 'utf8')
// 摘要
const summary = {
verdict: report.verdict,
seed: report.seed,
logic: `${report.logic.passed}/${report.logic.passed + report.logic.failed}`,
uiActions: report.ui.actionsDone,
ipcCalls: report.ipc.calls,
ipcErrors: report.ipc.errors.length,
scenariosOk: report.scenarios.ran.length,
scenariosFail: report.scenarios.failed.length,
crashes: report.ui.crashes.length,
consoleErrors: report.ui.consoleErrors.length,
pageErrors: report.ui.pageErrors.length,
dialogs: report.ui.dialogs,
outFile
}
console.log('\n========== COMPLEX MONKEY SUMMARY ==========')
console.log(JSON.stringify(summary, null, 2))
if (report.scenarios.failed.length) {
console.log('\nScenario failures:', report.scenarios.failed)
}
if (softIpc.length) console.log('\nIPC warnings:', softIpc.slice(0, 10))
if (report.ui.crashes.length) console.log('\nCrashes:', report.ui.crashes)
console.log(`\nFull report: ${outFile}`)
}
async function main() {
runLogicMonkey()
try {
await ensureBuild()
await runUiMonkey()
} catch (e) {
report.ui.crashes.push(String(e?.stack || e))
}
finalize()
process.exit(report.verdict === 'FAIL' ? 1 : 0)
}
main()