// 用 CDP (Chrome DevTools Protocol) 驱动真实 Electron 窗口: // 点开设备 -> 截图 -> 右键图片 -> 再截图。 // // 用途: 肉眼验证"图片直接显示 + 右键能复制图片"。环境里没有 xdotool, // 所以走 DevTools 协议自己点。 // // 用法: node scripts/selftest/cdp-shot.cjs [port] const fs = require('node:fs') const path = require('node:path') const WebSocket = require('ws') const outDir = process.argv[2] || '/tmp/lnm-shots' const port = process.argv[3] || '9222' function sleep(ms) { return new Promise(r => setTimeout(r, ms)) } async function findPage() { for (let i = 0; i < 40; i++) { try { const res = await fetch(`http://127.0.0.1:${port}/json/list`) const list = await res.json() const page = list.find(t => t.type === 'page' && t.webSocketDebuggerUrl) if (page) return page } catch { /* 还没起来 */ } await sleep(500) } throw new Error('连不上 CDP (应用没起来?)') } function connect(url) { return new Promise((resolve, reject) => { const ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 }) ws.once('open', () => resolve(ws)) ws.once('error', reject) }) } async function main() { fs.mkdirSync(outDir, { recursive: true }) const page = await findPage() const ws = await connect(page.webSocketDebuggerUrl) let id = 0 const pending = new Map() ws.on('message', (raw) => { const msg = JSON.parse(raw.toString()) if (msg.id && pending.has(msg.id)) { const { resolve, reject } = pending.get(msg.id) pending.delete(msg.id) if (msg.error) reject(new Error(JSON.stringify(msg.error))) else resolve(msg.result) } }) const send = (method, params) => new Promise((resolve, reject) => { const mid = ++id pending.set(mid, { resolve, reject }) ws.send(JSON.stringify({ id: mid, method, params: params || {} })) }) const evaluate = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }) if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ' ' + JSON.stringify(r.exceptionDetails.exception?.description || '')) return r.result?.value } const shot = async (name) => { const r = await send('Page.captureScreenshot', { format: 'png' }) const p = path.join(outDir, name) fs.writeFileSync(p, Buffer.from(r.data, 'base64')) console.log('截图: ' + p) return p } await send('Page.enable') await send('Runtime.enable') // 等 Vue 挂载 for (let i = 0; i < 30; i++) { const ok = await evaluate(`!!document.querySelector('.device-item')`) if (ok) break await sleep(500) } // 1) 点开设备 (侧边栏第一项) const clicked = await evaluate(`(() => { const el = document.querySelector('.device-item') if (!el) return false el.click() return true })()`) console.log('点击设备项: ' + clicked) await sleep(1500) // 2) 看看聊天区里有没有图片气泡 const info = await evaluate(`(() => { const imgs = [...document.querySelectorAll('.bubble.image img')] return { imageCount: imgs.length, srcPrefix: imgs[0] ? imgs[0].src.slice(0, 30) : '', naturalW: imgs[0] ? imgs[0].naturalWidth : 0, naturalH: imgs[0] ? imgs[0].naturalHeight : 0, fallbackCount: document.querySelectorAll('.image-fallback').length, bubbleText: [...document.querySelectorAll('.bubble')].map(b => b.innerText.trim().slice(0, 40)), } })()`) console.log('页面状态: ' + JSON.stringify(info, null, 2)) await shot('01-chat-image.png') // 3) 右键点图片 -> 看右键菜单 const ctx = await evaluate(`(() => { const img = document.querySelector('.bubble.image img') if (!img) return null const r = img.getBoundingClientRect() const x = Math.round(r.left + r.width / 2) const y = Math.round(r.top + r.height / 2) img.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: x, clientY: y })) return { x, y } })()`) console.log('右键坐标: ' + JSON.stringify(ctx)) await sleep(800) const menu = await evaluate(`(() => { const items = [...document.querySelectorAll('.ctx-menu .ctx-item')].map(e => e.innerText.trim()) return { items, hasCopyImage: items.includes('复制图片') } })()`) console.log('右键菜单: ' + JSON.stringify(menu, null, 2)) await shot('02-context-menu.png') // 4) 点"复制图片" -> 看提示条 (提示只在 imageCopy 返回 ok 时才会出现) const clickedCopy = await evaluate(`(() => { const items = [...document.querySelectorAll('.ctx-menu .ctx-item')] const it = items.find(e => e.innerText.trim() === '复制图片') if (!it) return false it.click() return true })()`) console.log('点击"复制图片": ' + clickedCopy) await sleep(1500) const toast = await evaluate(`(() => { const t = document.body.innerText return { copied: t.includes('图片已复制到剪贴板'), failed: t.includes('复制失败') || t.includes('还没有可复制') } })()`) console.log('提示条: ' + JSON.stringify(toast)) await shot('03-after-copy.png') // 5) 切到串口 tab -> 看看串口面板 const tab = await evaluate(`(() => { const btns = [...document.querySelectorAll('.header-tabs button')] const b = btns.find(x => x.innerText.trim() === '串口') if (!b) return { found: false, tabs: btns.map(x => x.innerText.trim()) } b.click() return { found: true } })()`) console.log('串口 tab: ' + JSON.stringify(tab)) await sleep(1500) const panel = await evaluate(`(() => { const p = document.querySelector('.serial-panel') if (!p) return { found: false } return { found: true, hasDeviceTable: !!p.querySelector('.el-table'), rows: p.querySelectorAll('.el-table tbody tr').length, hasAssistant: !!p.querySelector('.assistant'), hasHexView: !!p.querySelector('.hex-view'), buttons: [...p.querySelectorAll('button')].map(b => b.innerText.trim()).filter(Boolean), text: p.innerText.slice(0, 300), } })()`) console.log('串口面板: ' + JSON.stringify(panel, null, 2)) await shot('04-serial-panel.png') ws.close() console.log('done') } main().catch(e => { console.error('CDP 失败:', e.message); process.exit(1) })