897 lines
27 KiB
JavaScript
897 lines
27 KiB
JavaScript
/**
|
||
* 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()
|