/** * 浏览器真实渲染验证:打开页面截图 + 断言关键内容 + 收集运行时错误。 * * 为什么需要它:编译通过(lint / vue-tsc / build)不等于页面能跑。 * 数据加载失败导致列表空白、解析逻辑只处理部分数据、组件渲染时抛错, * 这些问题在类型检查里完全看不出来,只有真实渲染才发现。 * * 用法: * 1. 起 dev server(默认 http://127.0.0.1:5173) * 2. node scripts/login.cjs # 抓验证码图片并认数字 * node scripts/login.cjs 5274 # 登录,写 .session.json * 3. node scripts/verify-pages.cjs * * 环境变量: * VERIFY_URL 目标地址,默认 http://127.0.0.1:5173/ * VERIFY_ROUTES 要验证的路由,逗号分隔,默认 /management/documents,/workbench/signing * VERIFY_OUT 截图输出目录,默认 /screenshots * CHROME_PATH Chrome 可执行文件路径 * * 依赖:ws(CDP 需要)。装到隔离 workspace: * cd C:/Users//.workbuddy-ai/binaries/node/workspace * /npm.cmd install ws * 运行时指定 NODE_PATH 指向该 workspace 的 node_modules。 * * ───────────────────────────────────────────────────────────── * 踩过的坑(改这个脚本前先读): * * 1. 登录不要 stub。后端对无效 token 返回 401 → utils/request.ts 会 * clearAuthStorage() + 跳登录页。伪造会话很容易被踢。走真实登录。 * 若一定要 stub XHR,必须实现 getAllResponseHeaders / getResponseHeader / * onloadend——只给 responseText 会让 axios 解析失败、Promise 永不 settle, * 路由守卫的 await 一直挂着 → #app 只剩 、整页空白, * 极易误判成"页面渲染挂了"。 * * 2. CDP 连续 Page.navigate 在 SPA 里不可靠:pushState 不触发 * Page.loadEventFired,轮询 readyState 又读到旧文档,断言会拿到上一页内容。 * 这里只做一次整页 navigate,后续切页用 router.push()。 * history.pushState + 手动派发 popstate 对 Vue Router 无效。 * * 3. Chrome 可执行路径写死容易失效,用 CHROME_PATH 覆盖。 */ const { spawn } = require('child_process') const fs = require('fs') const path = require('path') let WebSocket try { WebSocket = require('ws') } catch { console.error('缺少依赖 ws。请先安装:') console.error(' cd C:/Users//.workbuddy-ai/binaries/node/workspace') console.error(' /npm.cmd install ws') console.error('然后设置 NODE_PATH 指向该目录的 node_modules 再运行本脚本。') process.exit(1) } const CHROME = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe' const PORT = Number(process.env.VERIFY_CDP_PORT || 9448) const TARGET_URL = process.env.VERIFY_URL || 'http://127.0.0.1:5173/' const ROUTES = (process.env.VERIFY_ROUTES || '/management/documents,/workbench/signing') .split(',') .map((s) => s.trim()) .filter(Boolean) const OUT = process.env.VERIFY_OUT || path.resolve(__dirname, '../../screenshots') const SESSION_FILE = path.join(__dirname, '.session.json') const PROFILE = path.join( process.env.TEMP || process.env.TMPDIR || '/tmp', 'cdp-prof-verify-' + process.pid, ) fs.mkdirSync(OUT, { recursive: true }) fs.rmSync(PROFILE, { recursive: true, force: true }) if (!fs.existsSync(SESSION_FILE)) { console.error('缺少会话文件 ' + SESSION_FILE) console.error('请先执行: node scripts/login.cjs') process.exit(1) } const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')) console.log(`[会话] ${session.user.displayName} / token=${String(session.token).slice(0, 8)}…`) const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) // 先于应用代码执行,保证 store 初始化时就能读到会话 const BOOT_SCRIPT = ` try { localStorage.setItem('token', ${JSON.stringify(session.token)}) localStorage.setItem('tokenExpiresAt', ${JSON.stringify(session.expiresAt)}) localStorage.setItem('userInfo', ${JSON.stringify(JSON.stringify(session.user))}) } catch (e) {} ` async function assertReachable(url) { try { const res = await fetch(url) if (!res.ok) throw new Error('HTTP ' + res.status) } catch (err) { console.error(`\n[致命] 无法访问 ${url} —— ${err.message}`) console.error('请先启动 dev server,然后用 VERIFY_URL 指定正确地址。\n') process.exit(1) } } const chrome = spawn(CHROME, [ '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars', `--remote-debugging-port=${PORT}`, `--user-data-dir=${PROFILE}`, '--window-size=1680,1300', 'about:blank', ]) let msgId = 0 function send(ws, method, params = {}) { return new Promise((resolve, reject) => { const id = ++msgId const onMessage = (raw) => { const msg = JSON.parse(raw) if (msg.id !== id) return ws.off('message', onMessage) msg.error ? reject(new Error(method + ': ' + msg.error.message)) : resolve(msg.result) } ws.on('message', onMessage) ws.send(JSON.stringify({ id, method, params })) setTimeout(() => reject(new Error(method + ' 超时')), 25000) }) } async function evaluate(ws, expression) { const res = await send(ws, 'Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, }) if (res.exceptionDetails) { const d = res.exceptionDetails throw new Error('[evaluate] ' + (d.exception?.description || d.text || '').slice(0, 400)) } return res.result.value } async function shot(ws, name) { const { data } = await send(ws, 'Page.captureScreenshot', { format: 'png' }) const file = path.join(OUT, name) fs.writeFileSync(file, Buffer.from(data, 'base64')) console.log(' 截图 -> ' + file) } /** 页面内跳转:取 app 上的 router 实例 push,避免 CDP 连续 navigate 的坑 */ async function gotoInPage(ws, routePath, settleMs = 5000) { const result = await evaluate( ws, `(async () => { const app = document.getElementById('app') const router = app && app.__vue_app__ && app.__vue_app__.config.globalProperties.$router if (!router) return 'NO_ROUTER' await router.push(${JSON.stringify(routePath)}) await new Promise(r => setTimeout(r, 200)) return 'PUSHED:' + router.currentRoute.value.path })()`, ) console.log(' ' + result) await sleep(settleMs) } /** 页面通用断言:布局是否挂载、有无空态/错误提示、正文长度 */ const PAGE_PROBE = `(() => { const t = document.body.innerText const app = document.getElementById('app') return { path: location.pathname, appHtmlLen: app ? app.innerHTML.length : -1, vueMounted: !!(app && app.__vue_app__), hasLayout: !!document.querySelector('.clinical-layout'), bodyTextLen: t.length, hasEmptyHint: /暂无|没有|为空|加载失败|请从左侧列表选择/.test(t), hasErrorHint: /失败|错误|异常/.test(t), snippet: t.slice(0, 900), } })()` async function getTarget() { for (let i = 0; i < 40; i++) { try { const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json() const page = list.find((t) => t.type === 'page') if (page) return page } catch {} await sleep(300) } throw new Error('未找到页面') } const pageErrors = [] const apiCalls = [] ;(async () => { await assertReachable(TARGET_URL) let ws try { const target = await getTarget() ws = new WebSocket(target.webSocketDebuggerUrl, { perMessageDeflate: false }) ws.setMaxListeners(0) await new Promise((res, rej) => { ws.on('open', res) ws.on('error', rej) }) ws.on('message', (raw) => { const msg = JSON.parse(raw) if (msg.method === 'Runtime.exceptionThrown') { const d = msg.params.exceptionDetails pageErrors.push('[exception] ' + (d.exception?.description || d.text || '').slice(0, 300)) } if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') { const text = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(' ') pageErrors.push('[console.error] ' + text.slice(0, 300)) } if (msg.method === 'Network.responseReceived') { const r = msg.params.response if (r.url.includes('/api/v1/')) { apiCalls.push(r.status + ' ' + r.url.replace(/^https?:\/\/[^/]+/, '')) } } }) await send(ws, 'Page.enable') await send(ws, 'Runtime.enable') await send(ws, 'Network.enable') await send(ws, 'Page.addScriptToEvaluateOnNewDocument', { source: BOOT_SCRIPT }) // 只做一次整页导航,后续切页都在页面内完成 await send(ws, 'Page.navigate', { url: TARGET_URL }) await sleep(7000) const boot = await evaluate(ws, PAGE_PROBE) console.log('\n[启动态] ' + JSON.stringify(boot)) for (const [index, route] of ROUTES.entries()) { console.log(`\n=== ${route} ===`) await gotoInPage(ws, route, 6000) console.log(JSON.stringify(await evaluate(ws, PAGE_PROBE), null, 2)) const safe = route.replace(/^\//, '').replace(/\//g, '-') await shot(ws, String(index + 1).padStart(2, '0') + '-' + safe + '.png') } console.log('\n=== 后端接口调用 ===') const uniq = [...new Set(apiCalls)] console.log(uniq.length ? uniq.slice(0, 40).join('\n') : '(无)') console.log('\n=== 页面运行时错误 ===') console.log(pageErrors.length ? [...new Set(pageErrors)].slice(0, 20).join('\n') : '无(干净)') } catch (err) { console.error('验证失败:', err.message) process.exitCode = 1 } finally { if (ws) ws.close() chrome.kill() } })()