Files
xh-medical-sign-web/clinical-web/scripts/verify-editor-marks.cjs
T

349 lines
12 KiB
JavaScript

/**
* 编辑器占位符 / 勾选框胶囊的渲染验证。
*
* 验证目标(utils/signing-editor-marks + EditorWangPane):
* ① {患者姓名} 在编辑区渲染成带类型角标的胶囊,而不是和正文一样的裸文字;
* ② □ 渲染成勾选胶囊;
* ③ getHtml() 输出不变——用页面自己的「占位符检查」面板做探针
* (它读的就是序列化后的 HTML 字符串),面板能数出填写项即证明底层仍是 {…} 纯文本;
* ④ 光标落点没被装饰破坏:点进胶囊中部后插入字符,字符应落在占位符内部。
*
* 用法:
* 1. 起 dev server
* 2. VERIFY_BASE_URL=http://127.0.0.1:<port> node scripts/login.cjs # 抓验证码
* VERIFY_BASE_URL=http://127.0.0.1:<port> node scripts/login.cjs 1234 # 登录
* 3. VERIFY_URL=http://127.0.0.1:<port>/ node scripts/verify-editor-marks.cjs
*
* 依赖 ws,与 verify-pages.cjs 相同(NODE_PATH 指向隔离 workspace)。
*/
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')
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 || 9451)
const TARGET_URL = process.env.VERIFY_URL || 'http://127.0.0.1:5174/'
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-marks-' + process.pid,
)
fs.mkdirSync(OUT, { recursive: true })
fs.rmSync(PROFILE, { recursive: true, force: true })
if (!fs.existsSync(SESSION_FILE)) {
console.error('缺少会话文件 ' + SESSION_FILE + ',请先执行 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))
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) {}
`
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, clip) {
const { data } = await send(
ws,
'Page.captureScreenshot',
clip ? { format: 'png', clip } : { format: 'png' },
)
const file = path.join(OUT, name)
fs.writeFileSync(file, Buffer.from(data, 'base64'))
console.log(' 截图 -> ' + file)
}
/** 放大截取编辑区里的一行内容,用于人眼核对胶囊细节 */
async function zoomShot(ws, selector, name, scale = 3) {
const rect = await evaluate(
ws,
`(() => {
const el = document.querySelector(${JSON.stringify(selector)})
if (!el) return null
const r = el.getBoundingClientRect()
return { x: r.left, y: r.top, width: r.width, height: r.height }
})()`,
)
if (!rect) {
console.log(' 放大截图跳过:找不到 ' + selector)
return
}
await shot(ws, name, { ...rect, scale })
}
const clickByText = (text, tag = 'button') => `
(() => {
const el = [...document.querySelectorAll('${tag}')]
.find(e => e.textContent.trim().includes(${JSON.stringify(text)}));
if (!el) return 'NOT_FOUND: ${text}';
el.click();
return 'CLICKED: ' + el.textContent.trim().slice(0, 30);
})()`
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)
}
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 EDITOR_PROBE = `(() => {
const root = document.querySelector('.pane-content')
if (!root) return { error: 'NO_EDITOR' }
const style = (el, pseudo) => getComputedStyle(el, pseudo)
const chips = [...root.querySelectorAll('.ms-sign-chip')]
const braces = [...root.querySelectorAll('.ms-sign-brace')]
const boxes = [...root.querySelectorAll('.ms-sign-box')]
const panel = document.querySelector('.placeholder-check')
return {
editorText: root.innerText.replace(/\\s+/g, ' ').trim(),
chips: chips.map((c) => ({
text: c.textContent,
tag: c.dataset.signTag || null,
kind: c.dataset.signKind || null,
required: c.dataset.signRequired || null,
display: style(c).display,
bg: style(c).backgroundColor,
color: style(c).color,
border: style(c).borderTopWidth + ' ' + style(c).borderTopColor,
afterContent: style(c, '::after').content,
title: c.title,
})),
braceCount: braces.length,
braceTexts: braces.map((b) => b.textContent),
braceSample: braces[0] ? {
fontSize: style(braces[0]).fontSize,
opacity: style(braces[0]).opacity,
color: style(braces[0]).color,
} : null,
boxes: boxes.map((b) => ({
text: b.textContent,
bg: style(b).backgroundColor,
border: style(b).borderTopWidth,
title: b.title,
})),
panelText: panel ? panel.innerText.replace(/\\s+/g, ' ').trim() : null,
panelItems: panel ? [...panel.querySelectorAll('.placeholder-check__item')].map((e) => e.textContent.replace(/\\s+/g, ' ').trim()) : [],
}
})()`
const SELECTION_PROBE = `(() => {
const sel = window.getSelection()
return {
collapsed: sel.isCollapsed,
anchorNodeText: sel.anchorNode ? sel.anchorNode.textContent : null,
anchorOffset: sel.anchorOffset,
focusNodeText: sel.focusNode ? sel.focusNode.textContent : null,
focusOffset: sel.focusOffset,
}
})()`
async function mouse(ws, type, x, y) {
await send(ws, 'Input.dispatchMouseEvent', {
type,
x: Math.round(x),
y: Math.round(y),
button: 'left',
buttons: type === 'mouseReleased' ? 0 : 1,
clickCount: 1,
pointerType: 'mouse',
})
}
const pageErrors = []
;(async () => {
try {
const res = await fetch(TARGET_URL)
if (!res.ok) throw new Error('HTTP ' + res.status)
} catch (err) {
console.error(`\n[致命] 无法访问 ${TARGET_URL} —— ${err.message}`)
console.error('请先启动 dev server,并用 VERIFY_URL 指定正确地址。\n')
process.exit(1)
}
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))
}
})
await send(ws, 'Page.enable')
await send(ws, 'Runtime.enable')
await send(ws, 'Page.addScriptToEvaluateOnNewDocument', { source: BOOT_SCRIPT })
await send(ws, 'Page.navigate', { url: TARGET_URL })
await sleep(7000)
const bootPath = await evaluate(ws, 'location.pathname')
console.log('\n[启动] pathname = ' + bootPath)
if (String(bootPath).includes('login')) {
throw new Error('会话已失效,落到了登录页。重新执行 node scripts/login.cjs')
}
console.log('\n=== 打开文档库 ===')
await gotoInPage(ws, '/management/documents', 6000)
console.log('\n=== 打开 Word 编辑器 ===')
console.log(' ' + (await evaluate(ws, clickByText('新增文档'))))
await sleep(1500)
const dialog = await evaluate(
ws,
`({
mask: document.querySelectorAll('.dialog-mask').length,
hasEditor: !!document.querySelector('.pane-content [data-slate-editor]'),
})`,
)
console.log(' ' + JSON.stringify(dialog))
if (!dialog.hasEditor) throw new Error('Word 编辑器没有打开')
console.log('\n=== 插入占位符与勾选框 ===')
for (const label of ['患者签名', '签署日期', '患者姓名', '勾选框 □', '二选一']) {
console.log(
' ' + label + ' -> ' + (await evaluate(ws, clickByText(label, '.placeholder-chip'))),
)
await sleep(400)
}
await sleep(1200)
console.log('\n=== 编辑区渲染探针 ===')
const probe = await evaluate(ws, EDITOR_PROBE)
console.log(JSON.stringify(probe, null, 2))
await shot(ws, '04-editor-marks.png')
await zoomShot(ws, '.pane-content [data-slate-editor] p', '04b-editor-marks-zoom.png', 4)
console.log('\n=== 光标落点(点进胶囊中部后插入 X) ===')
const chipBox = await evaluate(
ws,
`(() => {
const chip = document.querySelector('.ms-sign-chip')
if (!chip) return null
const r = chip.getBoundingClientRect()
return { x: r.left, y: r.top, w: r.width, h: r.height }
})()`,
)
if (chipBox) {
const cx = chipBox.x + chipBox.w * 0.3
const cy = chipBox.y + chipBox.h / 2
await mouse(ws, 'mousePressed', cx, cy)
await mouse(ws, 'mouseReleased', cx, cy)
await sleep(500)
console.log(' 点击前选中态: ' + JSON.stringify(await evaluate(ws, SELECTION_PROBE)))
await send(ws, 'Input.insertText', { text: 'X' })
await sleep(600)
const after = await evaluate(ws, EDITOR_PROBE)
console.log(' 插入 X 后正文: ' + after.editorText)
console.log(' 插入 X 后填写项面板: ' + after.panelText)
await shot(ws, '05-editor-marks-caret.png')
}
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()
}
})()