自定义渲染器渲染钩子改走 data.attrs 挂类名与 title:data.class 会被 wangEditor 的规范化函数搬进 props 且到不了 class 模块(胶囊不渲染), data.props.className 则因 props 模块只单向写入而摘不掉旧值,导致勾选框 退格删除后 DOM 仍残留 ms-sign-box 空壳。 同时移除必填标记相关的红角标、签名位专属橙色、插入按钮与「占位符检查」 面板里的 `*` / 「必填」文案,所有占位符共用一个样式。 verify-editor-marks.cjs 增加断言台账(PASS/FAIL 并反映退出码)、叶子结构 探针与退格删除回归,README 补充「踩过的坑 · 5」说明渲染通道的选择。
529 lines
20 KiB
JavaScript
529 lines
20 KiB
JavaScript
/**
|
||
* 编辑器占位符 / 勾选框胶囊的渲染验证。
|
||
*
|
||
* 验证目标(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:<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,
|
||
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()
|
||
}
|
||
})()
|