#!/usr/bin/env node /** * ui-check.mjs — 群聊台的点击手感验收。 * * 关键点:用 CDP 派发**真实的 mousePressed / mouseReleased**(不是 element.click())。 * 病根是"按钮在你的 mousedown 和 mouseup 之间被 innerHTML 重建掉,click 事件根本不会触发", * 只有真实鼠标事件才能复现它。 * * 量的是"点击 → 肉眼可见变化"的耗时(页面内 rAF 计时,分辨率约 1 帧)。 * * 用法:node tools/ui-check.mjs [url] [--keep] */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; const URL_ = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : 'http://127.0.0.1:3099/'; const KEEP = process.argv.includes('--keep'); const PORT = 9331; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const EDGE = ['C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'].find((p) => fs.existsSync(p)); if (!EDGE) { console.error('找不到 msedge.exe'); process.exit(2); } const results = []; function step(name, ok, detail = '') { results.push({ name, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' ' + detail : ''}`); } class Cdp { constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.errors = []; } static async connect(url) { const ws = new WebSocket(url); await new Promise((res, rej) => { ws.addEventListener('open', res, { once: true }); ws.addEventListener('error', () => rej(new Error('CDP 连接失败')), { once: true }); }); const c = new Cdp(ws); ws.addEventListener('message', (ev) => { let m; try { m = JSON.parse(ev.data); } catch { return; } if (m.id && c.pending.has(m.id)) { const { resolve, reject } = c.pending.get(m.id); c.pending.delete(m.id); if (m.error) reject(new Error(m.error.message)); else resolve(m.result); } else if (m.method === 'Runtime.exceptionThrown') { const d = m.params.exceptionDetails; c.errors.push('EXCEPTION: ' + (d.exception?.description || d.text || '').split('\n')[0]); } else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') { c.errors.push('console.error: ' + m.params.args.map((a) => a.value || a.description || '').join(' ')); } }); return c; } send(method, params = {}) { const id = ++this.id; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params })); setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(method + ' 超时')); } }, 30000); }); } async eval(expression, awaitPromise = false) { const r = await this.send('Runtime.evaluate', { expression, awaitPromise, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval 抛错'); return r.result?.value; } async rect(selector) { const j = await this.eval(`(() => { const e = document.querySelector(${JSON.stringify(selector)}); if (!e) return null; const r = e.getBoundingClientRect(); return {x: r.left + r.width/2, y: r.top + r.height/2, w: r.width, h: r.height}; })()`); return j; } /** 真实鼠标点击:按下与抬起分开派发,中间留出被"重建"打断的窗口。 */ async clickAt(x, y, { holdMs = 45 } = {}) { await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', clickCount: 0 }); await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 }); await sleep(holdMs); await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 }); } } /* --------------------------------------------------------------- 启动浏览器 */ const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'aig-ui-')); const edge = spawn(EDGE, [ '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--disable-extensions', '--hide-scrollbars', '--window-size=1440,1000', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, 'about:blank', ], { stdio: 'ignore', windowsHide: true }); async function browserWsUrl() { for (let i = 0; i < 60; i++) { try { const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json(); const page = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl); if (page) return page.webSocketDebuggerUrl; } catch { /* 还没起来 */ } await sleep(250); } throw new Error('浏览器调试端口没起来'); } let cdp; try { cdp = await Cdp.connect(await browserWsUrl()); await cdp.send('Runtime.enable'); await cdp.send('Page.enable'); await cdp.send('Page.navigate', { url: URL_ }); // 等页面真正就绪:出现成员卡片 for (let i = 0; i < 60; i++) { await sleep(250); const ready = await cdp.eval('document.querySelectorAll(".mcard").length').catch(() => 0); if (ready > 0) break; } await cdp.eval(`window.__awaitChange = function (done, timeoutMs) { return new Promise(function (resolve) { var t0 = performance.now(); function check() { var v = false; try { v = done(); } catch (e) {} if (v) return resolve(Math.round(performance.now() - t0)); if (performance.now() - t0 > timeoutMs) return resolve(-1); requestAnimationFrame(check); } requestAnimationFrame(check); }); };`); const members = await cdp.eval('[...document.querySelectorAll(".mcard")].map(c => c.dataset.member)'); step('页面渲染出成员卡片', Array.isArray(members) && members.length >= 3, `成员:${(members || []).join(', ')}`); /** 点击某个选择器指向的元素,并量到可见变化为止 */ async function clickAndMeasure(label, selector, condition, { holdMs = 45, timeout = 3000 } = {}) { const r = await cdp.rect(selector); if (!r) { step(label, false, '找不到元素 ' + selector); return -1; } await cdp.eval(`window.__p = window.__awaitChange(function(){ return ${condition}; }, ${timeout})`); await cdp.clickAt(r.x, r.y, { holdMs }); const ms = await cdp.eval('window.__p', true); return ms; } /* 1. @ 它(纯本地动作,应该感觉不到延迟) */ { const ms = await clickAndMeasure('点「@ 它」→ 目标切到该成员', '.mcard[data-member="opencode"] .mact button:nth-child(2)', 'document.getElementById("target").value === "opencode"'); step('点「@ 它」立刻切换目标', ms >= 0 && ms < 120, `${ms} ms`); } /* 2. 自驱开关 */ let autoBefore = null; { autoBefore = await fetch(URL_.replace(/\/$/, '') + '/api/state').then((r) => r.json()).then((s) => s.auto).catch(() => null); const before = await cdp.eval('document.getElementById("autoBtn").textContent'); const want = before.includes('开') ? '自驱:关' : '自驱:开'; const ms = await clickAndMeasure('点「自驱」→ 标签翻转', '#autoBtn', `document.getElementById("autoBtn").textContent.includes(${JSON.stringify(want)})`); step('点「自驱」立刻翻转', ms >= 0 && ms < 400, `${ms} ms(${before.trim()} → ${want})`); } /* 3. 发送:自己的话必须立刻出现在屏幕上(乐观回显) */ const sendText = 'UI 验收 ' + Date.now() + ':这条应当立刻出现'; { await cdp.eval(`(() => { const t = document.getElementById('text'); t.value = ${JSON.stringify(sendText)}; t.dispatchEvent(new Event('input')); document.getElementById('target').value = '__all__'; return true; })()`); const ms = await clickAndMeasure('点「只发不唤醒」→ 立刻看到自己说的话', '#sendOnlyBtn', `[...document.querySelectorAll(".row.pending .text")].some(e => e.textContent.includes(${JSON.stringify(sendText)}))`, { timeout: 2000 }); step('发出的消息立刻回显(乐观渲染)', ms >= 0 && ms < 150, `${ms} ms`); } /* 4. 服务端确认后不留重复气泡(文本带时间戳,避免和上一轮历史撞车) */ { let n = -1; for (let i = 0; i < 40; i++) { await sleep(150); n = await cdp.eval(`[...document.querySelectorAll(".text")].filter(e => e.textContent.includes(${JSON.stringify(sendText)})).length`); if (n >= 1) break; } await sleep(700); const after = await cdp.eval(`[...document.querySelectorAll(".text")].filter(e => e.textContent.includes(${JSON.stringify(sendText)})).length`); step('服务端确认后不出现重复气泡', after === 1, `气泡数 = ${after}`); } /* 5. 节点稳定性:成员卡片与按钮绝不能被周期重建(这就是"点击丢失"的根因) */ { const id1 = await cdp.eval(`(() => { const c = document.querySelector('.mcard[data-member="opencode"]'); c.__probe = Math.random().toString(36); const b = c.querySelector('.mact button'); b.__probe = Math.random().toString(36); return [c.__probe, b.__probe]; })()`); await sleep(6000); // 跨越 5s 的 state 轮询与 250ms 的任务计时器 const id2 = await cdp.eval(`(() => { const c = document.querySelector('.mcard[data-member="opencode"]'); const b = c ? c.querySelector('.mact button') : null; return { card: c && c.__probe, btn: b && b.__probe, connected: Boolean(c && c.isConnected) }; })()`); const same = id2.card === id1[0] && id2.btn === id1[1] && id2.connected; step('6 秒内成员卡片与按钮从未被重建', same, same ? '节点身份保持不变' : `卡片 ${id1[0]}→${id2.card},按钮 ${id1[1]}→${id2.btn}`); } /* 6. 渲染压力下连续快速点击不丢事件 —— 直接数按钮自己收到的 click 事件(这才是"点击有没有落到元素上"的判据; 上一版测试用 target.value 赋值来看 change 事件是错的:程序化赋值不触发 change) */ { const sel = '.mcard[data-member="workbuddy"] .mact button:nth-child(2)'; const r = await cdp.rect(sel); await cdp.eval(`(() => { const b = document.querySelector(${JSON.stringify(sel)}); b.__hits = 0; b.addEventListener('click', () => { b.__hits++; }); return true; })()`); // 制造真实的渲染压力:点的时候同时往群里灌消息,逼服务端推 SSE → 前端 syncMembers/loadState const churn = (async () => { for (let i = 0; i < 4; i++) { await fetch(URL_.replace(/\/$/, '') + '/api/send', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: '渲染压力 ' + i, to: [] }), }).catch(() => {}); await sleep(120); } })(); for (let i = 0; i < 8; i++) { await cdp.clickAt(r.x, r.y, { holdMs: 20 }); await sleep(55); } await churn; const hits = await cdp.eval(`document.querySelector(${JSON.stringify(sel)}).__hits`); step('渲染压力下连续 8 次快速点击全部命中', hits === 8, `命中 ${hits}/8`); } /* 6b. 服务端推送延迟:以 human 身份发一条,量它出现在屏幕上的时间 */ { const text = '推送延迟测量 ' + Date.now(); await cdp.eval(`window.__p = window.__awaitChange(function(){ return [...document.querySelectorAll('.text')].some(e => e.textContent.includes(${JSON.stringify(text)})); }, 4000)`); await fetch(URL_.replace(/\/$/, '') + '/api/send', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, to: [] }), }); const ms = await cdp.eval('window.__p', true); step('别人发的消息推到屏幕上', ms >= 0 && ms < 400, `${ms} ms`); } /* 7. 唤醒按钮:点下去必须立刻变"唤醒中…",并且能中止 */ { // 先把验收期间带起来的其它任务清干净,免得任务条重排把鼠标点偏(上一版就栽在这) const jj0 = await fetch(URL_.replace(/\/$/, '') + '/api/jobs').then((r) => r.json()).catch(() => ({ jobs: [] })); for (const j of (jj0.jobs || []).filter((x) => x.status === 'running')) { await fetch(URL_.replace(/\/$/, '') + '/api/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: j.id }) }).catch(() => {}); } for (let i = 0; i < 20; i++) { const st = await fetch(URL_.replace(/\/$/, '') + '/api/state').then((r) => r.json()).catch(() => ({ running: [] })); if (!(st.running || []).length) break; await sleep(300); } const ms = await clickAndMeasure('点「唤醒它」→ 立刻变唤醒中', '.mcard[data-member="workbuddy"] .mact button:nth-child(1)', '(() => { const b = document.querySelector(\'.mcard[data-member="workbuddy"] .mact button\'); return Boolean(b && b.disabled && /唤醒中|工作中/.test(b.textContent)); })()', { timeout: 3000 }); step('点「唤醒它」立刻给出反馈', ms >= 0 && ms < 200, `${ms} ms`); let running = null; for (let i = 0; i < 30; i++) { const jobs = await fetch(URL_.replace(/\/$/, '') + '/api/jobs').then((r) => r.json()).catch(() => ({ jobs: [] })); running = (jobs.jobs || []).find((j) => j.status === 'running' && j.member === 'workbuddy'); if (running) break; await sleep(300); } if (running) { const sel = `.row.typing[data-job="${running.id}"] .stop`; await cdp.eval(`(() => { const b = document.querySelector(${JSON.stringify(sel)}); if (b) b.scrollIntoView({block:'center'}); return true; })()`); await sleep(250); const stopRect = await cdp.rect(sel); // 点之前重取坐标 const msStop = stopRect ? await (async () => { await cdp.eval(`window.__p = window.__awaitChange(function(){ const b = document.querySelector(${JSON.stringify(sel)}); return Boolean(b && b.disabled === true); }, 3000)`); await cdp.clickAt(stopRect.x, stopRect.y); return cdp.eval('window.__p', true); })() : -1; step('点「中止」立刻有反馈', msStop >= 0 && msStop < 200, `${msStop} ms`); await fetch(URL_.replace(/\/$/, '') + '/api/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: running.id }) }); let cancelled = false; for (let i = 0; i < 40; i++) { await sleep(300); const jj = await fetch(URL_.replace(/\/$/, '') + '/api/jobs').then((r) => r.json()).catch(() => ({ jobs: [] })); const j = (jj.jobs || []).find((x) => x.id === running.id); if (j && j.status !== 'running') { cancelled = true; break; } } step('中止后任务真的停了', cancelled, running.id); let freed = false; for (let i = 0; i < 20; i++) { const st = await fetch(URL_.replace(/\/$/, '') + '/api/state').then((r) => r.json()).catch(() => ({ running: [] })); if (!(st.running || []).includes('workbuddy')) { freed = true; break; } await sleep(300); } step('中止后成员锁被释放', freed, ''); } else { step('中止按钮可用', false, '没找到正在跑的 workbuddy 任务'); } } /* 8. 页面无 JS 报错 */ await sleep(400); step('页面无未捕获异常', cdp.errors.length === 0, cdp.errors.slice(0, 2).join(' | ')); /* 收尾:把自驱恢复成本次验收开始时的状态(别把用户的开关改掉),并中止验收期间带起的任务 */ if (autoBefore !== null) { await fetch(URL_.replace(/\/$/, '') + '/api/auto', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ enabled: autoBefore }), }).catch(() => {}); } try { const jj = await fetch(URL_.replace(/\/$/, '') + '/api/jobs').then((r) => r.json()); for (const j of (jj.jobs || []).filter((x) => x.status === 'running')) { await fetch(URL_.replace(/\/$/, '') + '/api/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: j.id }) }); } if ((jj.jobs || []).some((x) => x.status === 'running')) step('收尾中止遗留任务', true, '已请求中止'); } catch { /* 忽略 */ } } catch (e) { step('验收脚本执行', false, e.message); } finally { try { cdp?.ws.close(); } catch { /* 忽略 */ } try { edge.kill(); } catch { /* 忽略 */ } if (!KEEP) { await sleep(300); try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* 忽略 */ } } } const bad = results.filter((r) => !r.ok); console.log('\n' + '─'.repeat(56)); console.log(bad.length ? `✘ ${results.length - bad.length}/${results.length} 通过,失败 ${bad.length} 项` : `✔ 全部通过(${results.length}/${results.length})`); process.exit(bad.length ? 1 : 0);