模板列表接口不返回正文,展示、编辑与发起签署前需再取版本详情获取 contentHtml, 为此新增 getTemplateContent、createTemplateWithContent、updateTemplateContent 与 deleteTemplate(归档),并在签署端按 contentSha256 缓存正文快照。 同时新增 scripts/login.cjs 与 scripts/verify-pages.cjs,通过真实登录与 CDP 渲染截图、断言关键内容并收集运行时错误,并补充 .gitignore、eslint 忽略与文档说明。
264 lines
9.6 KiB
JavaScript
264 lines
9.6 KiB
JavaScript
/**
|
||
* 浏览器真实渲染验证:打开页面截图 + 断言关键内容 + 收集运行时错误。
|
||
*
|
||
* 为什么需要它:编译通过(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()
|
||
}
|
||
})()
|