421 lines
16 KiB
JavaScript
421 lines
16 KiB
JavaScript
/**
|
||
* 签署页「点击文中签名位采集签名」的渲染与交互验证。
|
||
*
|
||
* 验证目标(utils/signing-document + SigningDocumentInteractive + 签署页):
|
||
* ① 签署态的文书里,空签名位渲染成可点击的采集入口
|
||
* (data-sign-action=signature、role=button、tabindex、✍ 点击签名 文案、手型光标);
|
||
* ② 患者签名与医师签名是两个独立签名位,都挂着采集入口;
|
||
* ③ 真实点击医师签名 → 打开签字板弹窗,标题是「采集「医师签名」」;
|
||
* ④ 在签字板上画一笔 → 确认 → 签名图回显到该签名位(img.sign-doc-signature),
|
||
* 且该位的采集入口被摘掉、页脚「还有签名位待采集」不再点名它;
|
||
* ⑤ 患者签名位不受影响,仍然可点;
|
||
* ⑥ 键盘 Enter 也能触发采集入口(签名位挂了 role=button,不能只有鼠标能用)。
|
||
*
|
||
* 用法:
|
||
* 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-signature-click.cjs
|
||
*
|
||
* 注意:后端会话只活约 10 分钟,login 与 verify 要串在同一条命令里跑。
|
||
* 依赖 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 || 9453)
|
||
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-signclick-' + 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 mouse(ws, type, x, y, extra = {}) {
|
||
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',
|
||
...extra,
|
||
})
|
||
}
|
||
|
||
async function clickAt(ws, x, y) {
|
||
await mouse(ws, 'mousePressed', x, y)
|
||
await sleep(40)
|
||
await mouse(ws, 'mouseReleased', x, y)
|
||
}
|
||
|
||
/** 元素中心点(视口坐标),元素不存在返回 null */
|
||
async function centerOf(ws, selector) {
|
||
return evaluate(
|
||
ws,
|
||
`(() => {
|
||
const el = document.querySelector(${JSON.stringify(selector)})
|
||
if (!el) return null
|
||
const r = el.getBoundingClientRect()
|
||
if (!r.width || !r.height) return null
|
||
return { x: r.left + r.width / 2, y: r.top + r.height / 2, w: r.width, h: r.height }
|
||
})()`,
|
||
)
|
||
}
|
||
|
||
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 DOC_PROBE = `(() => {
|
||
const body = document.querySelector('.sign-doc-body')
|
||
if (!body) return { error: 'NO_DOC_BODY' }
|
||
const hosts = [...body.querySelectorAll('[data-sign-field]')]
|
||
const sigHosts = hosts.filter((h) => h.dataset.signKind === 'signature')
|
||
const footer = document.querySelector('.sign-doc-footer')
|
||
const card = document.querySelector('.signature-dialog')
|
||
return {
|
||
fieldIds: hosts.map((h) => h.dataset.signField),
|
||
signatureHosts: sigHosts.map((h) => {
|
||
const img = h.querySelector('img.sign-doc-signature')
|
||
const cs = getComputedStyle(h)
|
||
return {
|
||
id: h.dataset.signField,
|
||
label: h.dataset.signLabel,
|
||
action: h.dataset.signAction || null,
|
||
role: h.getAttribute('role'),
|
||
tabindex: h.getAttribute('tabindex'),
|
||
title: h.title || null,
|
||
actionableClass: h.classList.contains('sign-field--actionable'),
|
||
controlText: (h.querySelector('.sign-field__control') || {}).textContent?.trim() ?? null,
|
||
cursor: cs.cursor,
|
||
background: cs.backgroundColor,
|
||
borderColor: cs.borderTopColor,
|
||
imageSrcPrefix: img ? img.src.slice(0, 22) : null,
|
||
imageSrcLength: img ? img.src.length : 0,
|
||
}
|
||
}),
|
||
footer: footer ? footer.innerText.replace(/\\s+/g, ' ').trim() : null,
|
||
dialog: card
|
||
? {
|
||
title: card.querySelector('.dialog-header strong')?.textContent.trim() ?? null,
|
||
hint: card.querySelector('.dialog-hint')?.textContent.trim() ?? null,
|
||
buttons: [...card.querySelectorAll('button')].map((b) => b.textContent.trim()),
|
||
canvasInk: (() => {
|
||
const c = card.querySelector('canvas')
|
||
if (!c) return null
|
||
const ctx = c.getContext('2d')
|
||
const data = ctx.getImageData(0, 0, c.width, c.height).data
|
||
let painted = 0
|
||
for (let i = 3; i < data.length; i += 4) if (data[i] > 0) painted++
|
||
return painted
|
||
})(),
|
||
}
|
||
: null,
|
||
}
|
||
})()`
|
||
|
||
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, '/workbench/signing', 6000)
|
||
|
||
console.log('\n=== 选一个待签署任务 ===')
|
||
const picked = await evaluate(
|
||
ws,
|
||
`(() => {
|
||
const items = [...document.querySelectorAll('.task-item')]
|
||
if (!items.length) return 'NO_TASK_ITEM'
|
||
const pending = items.find((el) => el.textContent.includes('待签署'))
|
||
if (!pending) return 'NO_PENDING_TASK:' + items.length
|
||
pending.click()
|
||
return 'PICKED: ' + pending.textContent.replace(/\\s+/g, ' ').trim().slice(0, 60)
|
||
})()`,
|
||
)
|
||
console.log(' ' + picked)
|
||
if (String(picked).startsWith('NO_')) throw new Error('没有可用的待签署任务:' + picked)
|
||
await sleep(4000)
|
||
|
||
console.log('\n=== ① 签名位初始渲染探针 ===')
|
||
const before = await evaluate(ws, DOC_PROBE)
|
||
console.log(JSON.stringify(before, null, 2))
|
||
await shot(ws, '06-signature-actions.png')
|
||
|
||
if (before.error) throw new Error('文书没有渲染:' + before.error)
|
||
|
||
const actionable = before.signatureHosts.filter((h) => h.action === 'signature')
|
||
console.log(`\n 可点击签名位 ${actionable.length} 个:${actionable.map((h) => h.label).join('、')}`)
|
||
|
||
const doctor = before.signatureHosts.find((h) => h.label === '医师签名')
|
||
if (!doctor) throw new Error('文书里没有「医师签名」签名位,无法验证点击采集')
|
||
if (doctor.action !== 'signature') throw new Error('「医师签名」没有渲染成可点击采集入口')
|
||
if (doctor.cursor !== 'pointer') throw new Error('「医师签名」没有手型光标,观感上看不出能点')
|
||
if (!doctor.actionableClass) throw new Error('「医师签名」缺少 sign-field--actionable 类')
|
||
|
||
console.log('\n=== ② 真实点击「医师签名」 ===')
|
||
const doctorBox = await centerOf(ws, '[data-sign-field="doctor"]')
|
||
if (!doctorBox) throw new Error('找不到「医师签名」宿主节点的可点区域')
|
||
await clickAt(ws, doctorBox.x, doctorBox.y)
|
||
await sleep(1200)
|
||
|
||
const dialog = await evaluate(ws, DOC_PROBE)
|
||
console.log(' 弹窗:' + JSON.stringify(dialog.dialog))
|
||
if (!dialog.dialog) throw new Error('点击「医师签名」没有打开签字板弹窗')
|
||
if (!String(dialog.dialog.title).includes('医师签名')) {
|
||
throw new Error('弹窗标题没有指向被点的签名位:' + dialog.dialog.title)
|
||
}
|
||
await shot(ws, '07-signature-dialog.png')
|
||
|
||
console.log('\n=== ③ 在签字板上画一笔并确认 ===')
|
||
const canvasBox = await centerOf(ws, '.signature-dialog canvas')
|
||
if (!canvasBox) throw new Error('签字板 canvas 不可见')
|
||
const x0 = canvasBox.x - canvasBox.w * 0.3
|
||
const y0 = canvasBox.y
|
||
await mouse(ws, 'mousePressed', x0, y0)
|
||
for (let i = 1; i <= 12; i++) {
|
||
await mouse(ws, 'mouseMoved', x0 + i * (canvasBox.w * 0.05), y0 + (i % 2 ? 18 : -18))
|
||
await sleep(16)
|
||
}
|
||
await mouse(ws, 'mouseReleased', x0 + canvasBox.w * 0.6, y0)
|
||
await sleep(600)
|
||
|
||
const drawn = await evaluate(ws, DOC_PROBE)
|
||
console.log(' 画布着墨像素:' + drawn.dialog.canvasInk)
|
||
if (!drawn.dialog.canvasInk) throw new Error('签字板上没有画出内容,无法验证回显')
|
||
|
||
console.log(' ' + (await evaluate(ws, clickByText('确认采集'))))
|
||
await sleep(1500)
|
||
|
||
console.log('\n=== ④ 采集结果探针 ===')
|
||
const after = await evaluate(ws, DOC_PROBE)
|
||
console.log(JSON.stringify(after, null, 2))
|
||
await shot(ws, '08-signature-applied.png')
|
||
|
||
const doctorAfter = after.signatureHosts.find((h) => h.id === 'doctor')
|
||
if (!doctorAfter) throw new Error('采集后找不到「医师签名」签名位')
|
||
if (!doctorAfter.imageSrcPrefix?.startsWith('data:image/png')) {
|
||
throw new Error('「医师签名」没有回显签名图:' + doctorAfter.imageSrcPrefix)
|
||
}
|
||
if (doctorAfter.action) throw new Error('「医师签名」已采集,却还留着采集入口')
|
||
if (doctorAfter.actionableClass) throw new Error('「医师签名」已采集,却还留着可点击样式')
|
||
|
||
const patientAfter = after.signatureHosts.find((h) => h.id === 'signature')
|
||
if (!patientAfter) throw new Error('找不到「患者签名」签名位')
|
||
if (patientAfter.action !== 'signature') {
|
||
throw new Error('采集医师签名后,患者签名位的采集入口被误伤')
|
||
}
|
||
|
||
if (after.footer && after.footer.includes('医师签名')) {
|
||
throw new Error('页脚仍在提示「医师签名」待采集:' + after.footer)
|
||
}
|
||
|
||
console.log('\n=== ⑤ 键盘 Enter 触发「患者签名」 ===')
|
||
const focused = await evaluate(
|
||
ws,
|
||
`(() => {
|
||
const host = document.querySelector('[data-sign-field="signature"]')
|
||
if (!host) return 'NO_HOST'
|
||
host.focus()
|
||
return document.activeElement === host ? 'FOCUSED' : 'FOCUS_FAILED'
|
||
})()`,
|
||
)
|
||
console.log(' ' + focused)
|
||
await send(ws, 'Input.dispatchKeyEvent', {
|
||
type: 'keyDown',
|
||
key: 'Enter',
|
||
code: 'Enter',
|
||
windowsVirtualKeyCode: 13,
|
||
nativeVirtualKeyCode: 13,
|
||
})
|
||
await send(ws, 'Input.dispatchKeyEvent', {
|
||
type: 'keyUp',
|
||
key: 'Enter',
|
||
code: 'Enter',
|
||
windowsVirtualKeyCode: 13,
|
||
nativeVirtualKeyCode: 13,
|
||
})
|
||
await sleep(1200)
|
||
|
||
const keyboard = await evaluate(ws, DOC_PROBE)
|
||
console.log(' 弹窗:' + JSON.stringify(keyboard.dialog && keyboard.dialog.title))
|
||
if (!keyboard.dialog || !String(keyboard.dialog.title).includes('患者签名')) {
|
||
throw new Error('键盘 Enter 没有触发「患者签名」的采集入口')
|
||
}
|
||
await shot(ws, '09-signature-keyboard.png')
|
||
|
||
console.log('\n=== 页面运行时错误 ===')
|
||
console.log(pageErrors.length ? [...new Set(pageErrors)].slice(0, 20).join('\n') : '无(干净)')
|
||
if (pageErrors.length) process.exitCode = 1
|
||
} catch (err) {
|
||
console.error('验证失败:', err.message)
|
||
process.exitCode = 1
|
||
} finally {
|
||
if (ws) ws.close()
|
||
chrome.kill()
|
||
}
|
||
})()
|