initial commit
This commit is contained in:
94
scripts/gen-tray-icon.mjs
Normal file
94
scripts/gen-tray-icon.mjs
Normal 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
896
scripts/monkey-test.mjs
Normal 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()
|
||||
Reference in New Issue
Block a user