/** * 编辑器占位符 / 勾选框胶囊的渲染验证。 * * 验证目标(utils/signing-editor-marks + EditorWangPane): * ① {患者姓名} 在编辑区渲染成带类型角标的胶囊,而不是和正文一样的裸文字; * ② □ 渲染成勾选胶囊; * ③ getHtml() 输出不变——用页面自己的「占位符检查」面板做探针 * (它读的就是序列化后的 HTML 字符串),面板能数出填写项即证明底层仍是 {…} 纯文本; * ④ 光标落点没被装饰破坏:点进胶囊中部后插入字符,字符应落在占位符内部。 * * 用法: * 1. 起 dev server * 2. VERIFY_BASE_URL=http://127.0.0.1: node scripts/login.cjs # 抓验证码 * VERIFY_BASE_URL=http://127.0.0.1: node scripts/login.cjs 1234 # 登录 * 3. VERIFY_URL=http://127.0.0.1:/ 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//.workbuddy-ai/binaries/node/workspace') console.error(' /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() } })()