feat(signing): 支持点击文中签名位就地采集签名
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 临时复现脚本:插入勾选框后删除,看编辑器 DOM / 数据里有没有残留。
|
||||
* 用法:
|
||||
* node scripts/login.cjs <code> && 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()
|
||||
}
|
||||
})()
|
||||
Reference in New Issue
Block a user