/** * ui-check.mjs —— 浏览器端验收(Edge 无头 + CDP) * * 不只是"页面能打开":真的驱动界面做一次搜索、点选结果、等待清晰度解析, * 并读取渲染后的 DOM 断言每一处关键内容。 * * 用法: node tools/ui-check.mjs [baseUrl] */ import { spawn } from 'node:child_process'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import WebSocket from '../ui/node_modules/ws/index.js'; const BASE = process.argv[2] || 'http://127.0.0.1:8799/'; const PORT = 9333; const EDGE_CANDIDATES = [ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', ]; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const results = []; function check(name, ok, detail = '') { results.push({ name, ok, detail }); console.log(`${ok ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`); } async function main() { const fs = await import('node:fs'); const edge = EDGE_CANDIDATES.find((p) => fs.existsSync(p)); if (!edge) throw new Error('找不到 Edge'); const profile = mkdtempSync(join(tmpdir(), 'bd-ui-')); const child = spawn( edge, [ '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, '--window-size=1280,860', BASE, ], { stdio: 'ignore' }, ); let target = null; for (let i = 0; i < 40 && !target; i++) { await sleep(500); try { const res = await fetch(`http://127.0.0.1:${PORT}/json/list`); const list = await res.json(); target = list.find((t) => t.type === 'page' && t.url.startsWith('http')); } catch { /* 还没起来 */ } } if (!target) throw new Error('CDP 目标未就绪'); const ws = new WebSocket(target.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 }); await new Promise((res, rej) => { ws.once('open', res); ws.once('error', rej); }); let id = 0; const pending = new Map(); ws.on('message', (raw) => { const msg = JSON.parse(raw.toString()); if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); } }); const send = (method, params = {}) => new Promise((res) => { const myId = ++id; pending.set(myId, res); ws.send(JSON.stringify({ id: myId, method, params })); }); const evaluate = async (expression) => { const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, }); if (r.result?.exceptionDetails) { throw new Error(r.result.exceptionDetails.exception?.description || 'JS 异常'); } return r.result?.result?.value; }; await send('Runtime.enable'); await sleep(3500); // 等 React 挂载 + 首屏取配置/登录状态 console.log(`\n目标: ${BASE}\n`); // ---------- 1. 首屏渲染 ---------- const text = await evaluate('document.body.innerText'); check('React 已挂载并渲染', !!text && text.length > 60, `${text?.length ?? 0} 字符`); for (const key of ['BiliDownloader', '搜索站内视频', '未登录', '还没有选择视频', '清晰度', '开始下载', '日志']) { check(`首屏含「${key}」`, text.includes(key)); } // ---------- 2. 暗色主题 ---------- const isDark = await evaluate("document.documentElement.classList.contains('dark')"); check('默认深色主题生效', isDark === true); const bg = await evaluate( "getComputedStyle(document.body).backgroundColor", ); check('背景取自 shadcn token(非默认白)', bg !== 'rgb(255, 255, 255)' && bg !== 'rgba(0, 0, 0, 0)', bg); // ---------- 3. 无横向溢出 ---------- const overflow = await evaluate( 'document.documentElement.scrollWidth - document.documentElement.clientWidth', ); check('无横向溢出', overflow <= 0, `diff=${overflow}`); // ---------- 4. 真的搜一次 ---------- console.log('\n —— 驱动一次真实搜索 ——'); await evaluate(`(() => { const input = document.querySelector('input'); const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, '航拍中国'); input.dispatchEvent(new Event('input', { bubbles: true })); const form = input.closest('form'); form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); return true; })()`); await sleep(6000); const afterSearch = await evaluate('document.body.innerText'); const cardCount = await evaluate( "document.querySelectorAll('button[type=button]').length", ); check('搜索结果已渲染出卡片', cardCount >= 10, `${cardCount} 个可点卡片`); // ---------- 5. 点选一个结果 → 清晰度解析 ---------- console.log('\n —— 点选第一个结果,等待清晰度解析 ——'); await evaluate(`(() => { const btn = document.querySelector('button[type=button]'); btn.click(); return true; })()`); await sleep(9000); const afterPick = await evaluate('document.body.innerText'); check('已进入待下载状态(出现「保存到」)', afterPick.includes('保存到')); check('详情面板显示 BV 号', /BV[0-9A-Za-z]{10}/.test(afterPick)); check('清晰度已解析(出现「可用清晰度」日志或档位)', /可用清晰度|480P|360P|1080P|4K/.test(afterPick)); const hasStartEnabled = await evaluate(`(() => { const b = [...document.querySelectorAll('button')].find(x => x.textContent.includes('开始下载')); return b ? !b.disabled : null; })()`); check('「开始下载」按钮可点', hasStartEnabled === true); // ---------- 6. 控制台报错 ---------- const errs = await evaluate('window.__bdErrors ? window.__bdErrors.length : 0'); check('页面无致命脚本错误', errs === 0, `${errs} 个`); console.log('\n —— 渲染文本采样 ——'); console.log( afterPick .split('\n') .filter((l) => l.trim()) .slice(0, 26) .map((l) => ' ' + l.slice(0, 96)) .join('\n'), ); ws.close(); child.kill(); try { rmSync(profile, { recursive: true, force: true }); } catch {} const failed = results.filter((r) => !r.ok); console.log(`\n结果: ${results.length - failed.length}/${results.length} 通过`); if (failed.length) { console.log('失败项:'); for (const f of failed) console.log(` - ${f.name} ${f.detail}`); process.exit(1); } } main().catch((e) => { console.error('验收脚本异常:', e.message); process.exit(1); });