Files
ai-group-chat/tools/ui-check.mjs
T

314 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);