Files
xh-medical-sign-web/clinical-web/scripts/verify-pages.cjs
T
yelan 81922d774f feat(文档库): 打通模板正文真实模式并新增渲染验证
模板列表接口不返回正文,展示、编辑与发起签署前需再取版本详情获取 contentHtml,
为此新增 getTemplateContent、createTemplateWithContent、updateTemplateContent 与
deleteTemplate(归档),并在签署端按 contentSha256 缓存正文快照。

同时新增 scripts/login.cjs 与 scripts/verify-pages.cjs,通过真实登录与 CDP
渲染截图、断言关键内容并收集运行时错误,并补充 .gitignore、eslint 忽略与文档说明。
2026-09-17 14:10:45 +08:00

264 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 浏览器真实渲染验证:打开页面截图 + 断言关键内容 + 收集运行时错误。
*
* 为什么需要它:编译通过(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 截图输出目录,默认 <repo>/screenshots
* CHROME_PATH Chrome 可执行文件路径
*
* 依赖:ws(CDP 需要)。装到隔离 workspace:
* cd C:/Users/<you>/.workbuddy-ai/binaries/node/workspace
* <node>/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/<you>/.workbuddy-ai/binaries/node/workspace')
console.error(' <node>/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()
}
})()