/**
* 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=