initial commit

This commit is contained in:
2026-08-08 18:34:12 +08:00
commit 0d7438e368
82 changed files with 17407 additions and 0 deletions

94
scripts/gen-tray-icon.mjs Normal file
View File

@@ -0,0 +1,94 @@
import { deflateSync } from 'zlib'
import { writeFileSync, mkdirSync } from 'fs'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
/** 生成一个简洁的托盘图标(粉色唱片盘),输出到 resources/tray.png */
const __dirname = dirname(fileURLToPath(import.meta.url))
const SIZE = 32
const cx = SIZE / 2 - 0.5
const cy = SIZE / 2 - 0.5
const rOuter = 15
const rHole = 4
function px(x, y) {
const dx = x - cx
const dy = y - cy
const d = Math.sqrt(dx * dx + dy * dy)
// 抗锯齿边缘
const edge = (r) => Math.max(0, Math.min(1, r - d + 0.5))
const inDisc = edge(rOuter)
const inHole = edge(rHole)
if (inDisc <= 0) return [0, 0, 0, 0]
// 唱片主体粉色,中心孔洞白色
const pink = [255, 55, 95]
const white = [255, 255, 255]
const a = Math.round(255 * inDisc)
if (inHole > 0) {
const t = inHole
return [
Math.round(pink[0] * (1 - t) + white[0] * t),
Math.round(pink[1] * (1 - t) + white[1] * t),
Math.round(pink[2] * (1 - t) + white[2] * t),
a
]
}
return [pink[0], pink[1], pink[2], a]
}
// 构造原始 RGBA 扫描行(每行前置 filter 0
const raw = Buffer.alloc((SIZE * 4 + 1) * SIZE)
let p = 0
for (let y = 0; y < SIZE; y++) {
raw[p++] = 0
for (let x = 0; x < SIZE; x++) {
const [r, g, b, a] = px(x, y)
raw[p++] = r
raw[p++] = g
raw[p++] = b
raw[p++] = a
}
}
const crcTable = (() => {
const t = []
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
t[n] = c >>> 0
}
return t
})()
function crc32(buf) {
let c = 0xffffffff
for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
return (c ^ 0xffffffff) >>> 0
}
function chunk(type, data) {
const len = Buffer.alloc(4)
len.writeUInt32BE(data.length, 0)
const typeBuf = Buffer.from(type, 'ascii')
const body = Buffer.concat([typeBuf, data])
const crc = Buffer.alloc(4)
crc.writeUInt32BE(crc32(body), 0)
return Buffer.concat([len, body, crc])
}
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(SIZE, 0)
ihdr.writeUInt32BE(SIZE, 4)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // color type RGBA
const png = Buffer.concat([
sig,
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw)),
chunk('IEND', Buffer.alloc(0))
])
const out = resolve(__dirname, '../resources/tray.png')
mkdirSync(dirname(out), { recursive: true })
writeFileSync(out, png)
console.log('wrote', out, png.length, 'bytes')

896
scripts/monkey-test.mjs Normal file
View File

@@ -0,0 +1,896 @@
/**
* 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()