/** * 编辑器占位符 / 勾选框胶囊的渲染验证。 * * 验证目标(utils/signing-editor-marks + EditorWangPane + WordEditorDialog): * ① {患者姓名} 在编辑区渲染成带类型角标的胶囊,□ 渲染成勾选胶囊, * 而不是和正文一样的裸文字; * ② 所有占位符**共用一个样式**:胶囊之间配色/边框完全一致,没有「必填」红角标, * 没有签名位专属的橙色,插入按钮与「占位符检查」面板也不再出现 `*` / 「必填」; * ③ getHtml() 输出不变——用页面自己的「占位符检查」面板做探针 * (它读的就是序列化后的 HTML 字符串),面板能数出填写项即证明底层仍是 {…} 纯文本; * ④ 光标落点没被装饰破坏:点进胶囊中部后插入字符,字符应落在占位符内部; * ⑤ **删干净**:插入勾选框后按退格,编辑区 DOM 里不能残留任何 `ms-sign-*` 元素。 * 这一条是回归重点,也是最容易反复踩的地方——渲染通道选错会以两种方式坏掉: * · 走 `data.props.className`:挂得上,但 props 模块只单向写入、从不清理旧 prop, * 内容删了类名还挂在空叶子上(表现为擦不掉的浅蓝小方块); * · 走 `data.class`:wangEditor 的规范化函数(`RT`)白名单里没有 `class`, * 会被搬进 `data.props` 并删掉原键,class 模块读不到 → 类名压根不渲染。 * 正确通道是 `data.attrs`(在白名单里,且模块自带 removeAttribute)。 * 详见 signing-editor-marks.ts 的函数注释与 scripts/README.md「踩过的坑 · 5」。 * * 用法: * 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, display: style(c).display, bg: style(c).backgroundColor, color: style(c).color, border: style(c).borderTopWidth + ' ' + style(c).borderTopColor, afterContent: style(c, '::after').content, afterColor: style(c, '::after').color, title: c.getAttribute('title'), cls: c.className, })), 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.getAttribute('title'), cls: b.className, })), panelText: panel ? panel.innerText.replace(/\\s+/g, ' ').trim() : null, panelItems: panel ? [...panel.querySelectorAll('.placeholder-check__item')].map((e) => e.textContent.replace(/\\s+/g, ' ').trim()) : [], insertButtons: [...document.querySelectorAll('.placeholder-chip')].map((b) => b.textContent.trim()), } })()` /** * 叶子结构探针:把编辑区每个段落的叶子原样 dump 出来。 * 用来抓「数据已删、DOM 里类名还挂着」的残留——只看 querySelectorAll('.ms-sign-box') * 会漏掉那些类名被挂在 zero-width 空叶子上的情况。 */ const LEAF_PROBE = `(() => { const root = document.querySelector('.pane-content [data-slate-editor]') if (!root) return { error: 'NO_EDITOR' } const codes = (s) => [...s].map((c) => (c.codePointAt(0) > 126 ? 'U+' + c.codePointAt(0).toString(16).toUpperCase() : c)).join('') const blocks = [...root.querySelectorAll('[data-slate-node="element"]')] return { blocks: blocks.map((el) => ({ tag: el.tagName, leaves: [...el.querySelectorAll('[data-slate-leaf]')].map((leaf) => { const span = leaf.firstElementChild return { cls: span ? span.className : null, text: span ? span.textContent : null, codes: span ? codes(span.textContent || '') : null, zeroWidth: span ? span.getAttribute('data-slate-zero-width') : null, title: span ? span.getAttribute('title') : null, outer: span ? span.outerHTML : null, } }), })), } })()` 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', }) } async function key(ws, k, code, vk) { for (const type of ['keyDown', 'keyUp']) { await send(ws, 'Input.dispatchKeyEvent', { type, key: k, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, }) } } const pageErrors = [] /** 断言台账:脚本不做「只打印不判断」,结论必须一眼可读 */ const checks = [] function check(name, ok, detail) { checks.push({ name, ok: !!ok }) console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`) } ;(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=== 插入占位符与勾选框 ===') // 勾选框必须最后插入:退格是逐字符删,只有让 □ 成为最后一个字符, // 才会走到「叶子变空」那条路径——残留 bug 只在那条路径上出现。 const INSERT_LABELS = ['患者签名', '签署日期', '患者姓名', '二选一 □是 □否', '勾选框 □'] for (const label of INSERT_LABELS) { 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=== 断言:胶囊渲染与样式统一 ===') check( '占位符渲染成胶囊(.ms-sign-chip ≥ 1)', probe.chips.length >= 1, `${probe.chips.length} 个`, ) check( '勾选框渲染成胶囊(.ms-sign-box ≥ 1)', probe.boxes.length >= 1, `${probe.boxes.length} 个`, ) check( '每个胶囊都有类型角标(::after 有文案)', probe.chips.length > 0 && probe.chips.every( (c) => c.afterContent && c.afterContent !== 'none' && c.afterContent !== '""', ), probe.chips.map((c) => `${c.text}=${c.afterContent}`).join(' | '), ) const skin = (c) => `${c.bg}/${c.color}/${c.border}/${c.afterColor}` const skins = [...new Set(probe.chips.map(skin))] check( '所有占位符共用一个样式(配色/边框/角标色完全一致)', probe.chips.length >= 2 && skins.length === 1, skins.length === 1 ? skins[0] : `出现 ${skins.length} 种:${skins.join(' ;; ')}`, ) check( '胶囊上不再有必填标记(无 data-sign-required、无 `*` 角标)', probe.chips.every( (c) => !c.cls.includes('required') && !String(c.afterContent).includes('*'), ), probe.chips.map((c) => c.cls).join(' | '), ) check( '插入按钮文字不再带 `*`', probe.insertButtons.every((t) => !t.includes('*')), probe.insertButtons.join(' / '), ) check( '「占位符检查」面板不再出现「必填」', probe.panelItems.every((t) => !t.includes('必填')), probe.panelItems.join(' / '), ) check( '底层仍是 {…} 纯文本(面板能数出填写项)', /个填写项/.test(probe.panelText || '') && !/0 个填写项/.test(probe.panelText || ''), probe.panelText, ) // 删除测试必须紧跟插入、且在光标测试之前: // 插入后光标就在文末,退格才删得到最后那个 □; // 光标测试会往胶囊里插字符,之后光标不在文末,退格删的就是别的字符了。 console.log('\n=== 断言:删掉勾选框后 DOM 不残留 ===') console.log(' 按一次退格(删掉最后插入的 □)') await key(ws, 'Backspace', 'Backspace', 8) await sleep(1000) const leaves = await evaluate(ws, LEAF_PROBE) const afterDelete = await evaluate(ws, EDITOR_PROBE) const allLeaves = leaves.blocks.flatMap((b) => b.leaves) // 残留的确切签名:叶子已经空了(zero-width 或空文本),类名却还挂着 const residue = allLeaves.filter( (l) => l.cls && l.cls.includes('ms-sign-') && (l.zeroWidth || !l.text), ) const countBoxes = (text) => ((text || '').match(/□/g) || []).length console.log( ' [退格后胶囊] ' + JSON.stringify({ chips: afterDelete.chips.map((c) => c.text), braces: afterDelete.braceTexts, boxes: afterDelete.boxes.map((b) => b.text), }), ) console.log( ' [退格后叶子] ' + JSON.stringify(allLeaves.map((l) => ({ cls: l.cls, codes: l.codes }))), ) await shot(ws, '05b-editor-marks-delete.png') check( '勾选框已被删除(.ms-sign-box 比插入时少一个)', afterDelete.boxes.length === probe.boxes.length - 1, `${probe.boxes.length} -> ${afterDelete.boxes.length}`, ) check( '勾选框字符已被删除(正文 □ 比插入时少一个)', countBoxes(afterDelete.editorText) === countBoxes(probe.editorText) - 1, `${countBoxes(probe.editorText)} -> ${countBoxes(afterDelete.editorText)}`, ) check( 'DOM 里的勾选胶囊数与正文 □ 数一致(没有多出来的空壳)', afterDelete.boxes.length === countBoxes(afterDelete.editorText), `boxes=${afterDelete.boxes.length} / □=${countBoxes(afterDelete.editorText)}`, ) check( '空叶子上没有残留 ms-sign-* 类名', residue.length === 0, residue.length ? residue.map((l) => `${l.cls}:${l.codes}`).join(' | ') : '干净', ) 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=== 断言:装饰没有破坏光标偏移 ===') check( '插入的字符落在占位符内部', /{[^}]*X[^}]*}/.test(after.editorText), after.editorText, ) } else { check('插入的字符落在占位符内部', false, '找不到可点的胶囊') } console.log('\n=== 页面运行时错误 ===') console.log(pageErrors.length ? [...new Set(pageErrors)].slice(0, 20).join('\n') : '无(干净)') const failed = checks.filter((c) => !c.ok) console.log(`\n=== 结论:${checks.length - failed.length}/${checks.length} 通过 ===`) if (failed.length) { failed.forEach((c) => console.log(' 未通过:' + c.name)) process.exitCode = 1 } } catch (err) { console.error('验证失败:', err.message) process.exitCode = 1 } finally { if (ws) ws.close() chrome.kill() } })()