/** * 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)), '', "' 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=