/** * 临时复现脚本:插入勾选框后删除,看编辑器 DOM / 数据里有没有残留。 * 用法: * node scripts/login.cjs && node scripts/repro-checkbox-delete.cjs */ const { spawn } = require('child_process') const fs = require('fs') const path = require('path') const WebSocket = require('ws') const CHROME = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe' const PORT = Number(process.env.VERIFY_CDP_PORT || 9461) const TARGET_URL = process.env.VERIFY_URL || 'http://127.0.0.1:5173/' 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 || '/tmp', 'cdp-prof-repro-' + process.pid) fs.mkdirSync(OUT, { recursive: true }) fs.rmSync(PROFILE, { recursive: true, force: true }) const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')) 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' }, ) fs.writeFileSync(path.join(OUT, name), Buffer.from(data, 'base64')) console.log(' 截图 -> ' + path.join(OUT, name)) } 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 key(ws, k, code, vk) { await send(ws, 'Input.dispatchKeyEvent', { type: 'keyDown', key: k, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, }) await send(ws, 'Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, }) } 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('未找到页面') } /** 把编辑区每个段落的叶子结构原样 dump 出来:类名 + 文本(含不可见字符的码位) */ 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 { editableHtml: root.innerHTML, blocks: blocks.map((el) => ({ tag: el.tagName, elementHtml: el.innerHTML, 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, outer: span ? span.outerHTML : null, } }), textNodes: [...el.querySelectorAll('[data-slate-node="text"]')].length, })), } })()` const pageErrors = [] ;(async () => { 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('会话已失效') console.log('\n=== 打开文档库 → 新增文档 ===') await gotoInPage(ws, '/management/documents', 6000) console.log(' ' + (await evaluate(ws, clickByText('新增文档')))) await sleep(2500) const hasEditor = await evaluate( ws, `!!document.querySelector('.pane-content [data-slate-editor]')`, ) if (!hasEditor) throw new Error('编辑器没打开') console.log('\n=== 在空段落里插入勾选框 □ ===') console.log(' ' + (await evaluate(ws, clickByText('勾选框 □', '.placeholder-chip')))) await sleep(1200) console.log('\n[插入后]') console.log(JSON.stringify(await evaluate(ws, LEAF_PROBE), null, 2)) await shot(ws, 'tmp-repro-01-inserted.png') console.log('\n=== 按一次退格 ===') await key(ws, 'Backspace', 'Backspace', 8) await sleep(1000) console.log('\n[退格后]') console.log(JSON.stringify(await evaluate(ws, LEAF_PROBE), null, 2)) await shot(ws, 'tmp-repro-02-backspace.png') console.log('\n=== 再插一次 □ 并插入 二选一,然后用 Ctrl+A + Delete 全删 ===') console.log(' ' + (await evaluate(ws, clickByText('勾选框 □', '.placeholder-chip')))) await sleep(600) console.log(' ' + (await evaluate(ws, clickByText('二选一 □是 □否', '.placeholder-chip')))) await sleep(1000) console.log(JSON.stringify(await evaluate(ws, LEAF_PROBE), null, 2)) await key(ws, 'a', 'KeyA', 65) await sleep(300) await key(ws, 'Delete', 'Delete', 46) await sleep(1000) console.log('\n[全选删除后]') console.log(JSON.stringify(await evaluate(ws, LEAF_PROBE), null, 2)) await shot(ws, 'tmp-repro-03-selectall-delete.png') console.log('\n=== 页面运行时错误 ===') console.log(pageErrors.length ? [...new Set(pageErrors)].join('\n') : '无(干净)') } catch (err) { console.error('复现失败:', err.message) process.exitCode = 1 } finally { if (ws) ws.close() chrome.kill() } })()