/** * 桌面客户端 Phase 3 验收:写信 + 附件 + 权限面板。 * * # 为什么是一条贯穿的路径,而不是三段独立的检查 * * 三段连成一条链之后,每一段的「成功」都能被**外部**核验,而不是只看界面说了什么: * * 1. 用桌面 UI 写信,**带一个附件**,发给 zcode * 2. 外部核验:网关里真的有这封信、真的挂着 1 个附件(界面说「已发送」不算证据) * 3. 这封信让 zcode 触发一次**真实的授权请求**(我们刚做的执行门禁) * 4. 在桌面的**授权**面板里点「同意」 * 5. 外部核验:决策被记录 **且** Agent 真的把命令执行了(标记文件出现) * * 第 5 步是关键 —— 它证明「桌面界面上的那一下点击」真的走到了 Agent 那侧。 * 只验界面变成「已同意」的话,一个只在本地改状态、根本没提交给网关的实现 * 也能全绿。 * * # 这个脚本抓到的第一个真缺陷(白屏) * * 第一次跑的时候,判据 A「应用起来了吗」就红了:`#root` 里**一个子节点都没有**。 * 根因是 `vite.config.ts` 没设 `base`,产物里写的是绝对路径 `/assets/index-xxx.js`; * 网关在 `/` 下伺服它没问题,但 Electron 用 `loadFile()` 从 * `file:///…/dist/index.html` 加载时,绝对路径会解析成 `file:///assets/…`(不存在)。 * 窗口标题、进程、CDP 全都正常,只有页面是白的。 * * 所以判据 A 必须放在最前面,而且要断言**渲染出来了**,不能只断言「进程活着」 * 或「页面加载完成」—— 后者在 JS 根本没加载时同样会成功。 * * 用法: * ADMIN_PW=<密码> node test/manual/desktop-phase3-verify.mjs * * 环境变量: * ADMIN_USER 登录用户名,默认 gui-lab * ADMIN_PW 必填 * AGENTMAIL_URL 网关地址,默认 http://127.0.0.1:8180 * DESKTOP_CDP 桌面应用的 CDP 端点,默认 http://127.0.0.1:9223 * DESKTOP_BIN 打包产物可执行文件;给了就由本脚本自己起(xvfb + CDP) * DESKTOP_LAUNCH 1/0,默认给了 DESKTOP_BIN 就起 * AGENT_NAME 收件 Agent,默认 zcode * PLAYWRIGHT playwright 入口 */ const PW = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs'; const { chromium } = await import(PW); const { spawn } = await import('node:child_process'); const { writeFileSync, mkdirSync } = await import('node:fs'); const { join } = await import('node:path'); const { existsSync, readFileSync } = await import('node:fs'); const CDP = process.env.DESKTOP_CDP || 'http://127.0.0.1:9223'; const GW = (process.env.AGENTMAIL_URL || 'http://127.0.0.1:8180').replace(/\/$/, ''); const API = `${GW}/api/v1`; const USER = process.env.ADMIN_USER || 'gui-lab'; const PASS = process.env.ADMIN_PW || ''; const AGENT = process.env.AGENT_NAME || 'zcode'; const BIN = process.env.DESKTOP_BIN || ''; const LAUNCH = process.env.DESKTOP_LAUNCH ? process.env.DESKTOP_LAUNCH === '1' : !!BIN; const MARK = `PHASE3-${Date.now()}`; const TMP = '/tmp/desktop-phase3'; const KEY_FILE = process.env.DESKTOP_KEY_FILE || '/root/gotmp/desktop-phase3-key.txt'; const failed = []; const chk = (name, ok, note = '') => { console.log(` ${ok ? '通过' : '失败'} ${name}${note ? ' — ' + note : ''}`); if (!ok) failed.push(name); }; if (!PASS) { console.error('需要 ADMIN_PW'); process.exit(2); } // ─── 外部观察者:直接打网关 API(与界面完全独立的第二条路)────────────── let cookie = ''; async function api(path, init = {}) { const res = await fetch(API + path, { ...init, headers: { 'Content-Type': 'application/json', ...(cookie ? { Cookie: cookie } : {}), ...(init.headers || {}) } }); const text = await res.text(); let body = {}; try { body = text ? JSON.parse(text) : {}; } catch { body = { raw: text.slice(0, 300) }; } return { status: res.status, body, setCookie: res.headers.get('set-cookie') || '' }; } async function login() { const r = await api('/auth/login', { method: 'POST', body: JSON.stringify({ username: USER, password: PASS }) }); if (r.status !== 200) throw new Error(`登录失败 HTTP ${r.status} ${JSON.stringify(r.body).slice(0, 200)}`); cookie = r.setCookie.split(';')[0]; } /** 收件箱里带这个标记、且**不是**权限请求的信(权限请求里会带命令原文)。 */ async function findSent(mark) { for (const box of ['/me/mail/sent', '/me/mail/inbox']) { const r = await api(`${box}?limit=30`); const mails = r.body?.mails || []; const hit = mails.find( m => `${m.subject || ''} ${m.body || ''}`.includes(mark) && !String(m.subject || '').includes('权限请求') ); if (hit) return { ...hit, box }; } return null; } // ─── 起应用(可选)──────────────────────────────────────────────────── let child = null; async function ensureCdp() { for (let i = 0; i < 40; i++) { try { const r = await fetch(`${CDP}/json/version`); if (r.ok) return true; } catch { /* 还没起来 */ } await new Promise(r => setTimeout(r, 500)); } return false; } if (LAUNCH) { if (!existsSync(BIN)) { console.error(`找不到可执行文件:${BIN}`); process.exit(2); } console.log(`启动桌面应用:${BIN}`); child = spawn( 'xvfb-run', ['-a', '-s', '-screen 0 1400x900x24', BIN, '--no-sandbox', '--disable-gpu', `--remote-debugging-port=${new URL(CDP).port}`], { env: { ...process.env, AGENTMAIL_GATEWAY_URL: GW, DBUS_SESSION_BUS_ADDRESS: 'disabled:' }, stdio: ['ignore', 'pipe', 'pipe'] } ); child.stdout.on('data', () => {}); child.stderr.on('data', () => {}); } const up = await ensureCdp(); if (!up) { console.error(`CDP 端点不可用:${CDP}`); if (child) child.kill('SIGKILL'); process.exit(2); } const browser = await chromium.connectOverCDP(CDP); const ctx = browser.contexts()[0] ?? (await browser.newContext()); const page = ctx.pages().find(p => p.url().startsWith('file:') || p.url().includes('index.html')) ?? ctx.pages()[0]; if (!page) { console.error('没找到应用窗口'); process.exit(2); } await page.setViewportSize({ width: 1280, height: 800 }); const consoleErrors = []; page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 200)); }); page.on('pageerror', e => consoleErrors.push('pageerror: ' + String(e).slice(0, 200))); const shot = n => page.screenshot({ path: join(TMP, `${n}.png`) }).catch(() => {}); mkdirSync(TMP, { recursive: true }); try { console.log(`\n桌面 Phase 3 验收(标记 ${MARK})\n`); // ─── A. 应用真的渲染出来了吗(白屏判据)─────────────────────────── await page.waitForTimeout(2500); const boot = await page.evaluate(() => { const root = document.getElementById('root'); return { title: document.title, children: root ? root.children.length : -1, textLen: (root?.innerText || '').trim().length, apiBase: window.__AGENTMAIL_API_BASE__ || '' }; }); // ★ 这一条必须放最前面:JS 没加载时,后面每一条都会以奇怪的方式失败, // 而真正的原因(资源路径)只以一条不起眼的资源错误出现。 chk( 'A. 应用渲染出了内容(不是白屏)', boot.children > 0 && boot.textLen > 0, `#root 子节点=${boot.children} 文本=${boot.textLen} 字` ); chk('A. preload 注入的 API 基地址正确', boot.apiBase === API, `实际 ${boot.apiBase || '(空)'} 期望 ${API}`); // 资源加载失败会让「白屏」看起来像「后端不可用」,单独看一眼 const resErr = consoleErrors.filter(t => /Failed to load resource/.test(t)); chk('A. 没有资源加载失败', resErr.length === 0, resErr.slice(0, 2).join(' | ')); if (failed.length) await shot('00-blank'); // ─── B. 登录 ────────────────────────────────────────────────────── // 登录页在**桌面壳**里给的是「用户密钥」,不是账号密码 —— 见 // src/components/LoginPage.tsx:file:// 是不透明源,SameSite=Lax 的会话 Cookie // 存不下来,账号密码那条路在这里注定失败(而且会静默失败)。 const needLogin = (await page.locator('input[type=password]').count()) > 0; let how = '已是登录态'; if (needLogin) { const keyField = page.locator('#login-user-key'); if (await keyField.count()) { const key = process.env.DESKTOP_KEY || (existsSync(KEY_FILE) ? readFileSync(KEY_FILE, 'utf8').trim() : ''); if (!key) { chk('B. 登录', false, `桌面壳需要用户密钥:设 DESKTOP_KEY 或写到 ${KEY_FILE}`); throw new Error('缺用户密钥'); } await keyField.fill(key); await page.locator('button[type=submit]').first().click(); how = '用用户密钥(Bearer)进入'; } else { await page.locator('input[type=text], input:not([type])').first().fill(USER); await page.locator('input[type=password]').fill(PASS); await page.locator('button[type=submit]').first().click(); how = '用账号密码登录(浏览器壳)'; } await page.waitForTimeout(3500); } const loggedIn = (await page.locator('input[type=password]').count()) === 0; chk('B. 登录成功(离开登录页)', loggedIn, how); await shot('10-inbox'); await login(); // 外部观察者 chk('B. 外部观察者登录成功', cookie.length > 0); // ─── C. 写信 + 附件 + 发送 ──────────────────────────────────────── await page.locator('button[title="新建邮件"], button:has-text("新建")').first().click(); await page.waitForTimeout(800); await page.locator('input[placeholder*="deepseekharness"]').first().fill(AGENT); await page.locator('input[placeholder="更新特性分支"]').fill(`Phase3 验收 ${MARK}`); await page.locator('textarea').first().fill( `请用 run_command 执行:echo ${MARK} > /tmp/desktop-phase3/${MARK}.txt\n` + `然后回信告诉我结果。这是需要授权的动作,如果被拒绝请说明原因。` ); // 附件:input 是 hidden 的,交给 playwright 直接塞文件(真实用户点的是「添加附件」) const attachPath = join(TMP, `${MARK}.txt`); writeFileSync(attachPath, `Phase3 附件内容 ${MARK}\n`); await page.locator('input[type=file]').setInputFiles(attachPath); // 上传是异步的:等「1 个附件」出现,而不是设完就往下走 let attachSeen = false; for (let i = 0; i < 20; i++) { const t = await page.locator('body').innerText(); if (/1 个附件/.test(t)) { attachSeen = true; break; } await page.waitForTimeout(500); } chk('C. 附件上传完成(界面显示 1 个附件)', attachSeen); await shot('20-compose'); await page.locator('button:has-text("发送")').last().click(); let sentMsg = ''; for (let i = 0; i < 30; i++) { sentMsg = await page.locator('body').innerText(); if (/已发送/.test(sentMsg)) break; await page.waitForTimeout(500); } chk('C. 界面报告已发送', /已发送/.test(sentMsg), sentMsg.match(/已发送[^\n]{0,40}/)?.[0] || ''); // 外部核验:信真的在网关里、真的带附件 let mail = null; for (let i = 0; i < 20; i++) { mail = await findSent(MARK); if (mail) break; await new Promise(r => setTimeout(r, 1000)); } chk('C. 外部核验:信真的到了网关', !!mail, mail ? `${mail.box} ${mail.subject}` : '找不到'); if (mail) { // 端点要选对:单封邮件是 `/mail/{id}`(挂在 /me 组下但路径不带 me 前缀), // `/me/mail/{id}` 不存在、会 404 —— 判据写错端点时会以「附件是空的」现形, // 看起来像功能 bug。实测踩过一次。 const detail = await api(`/mail/${mail.mail_id}`); const att = detail.body?.attachments || []; chk( 'C. 外部核验:附件真的挂在信上', att.length === 1 && String(att[0]?.filename || '').includes(MARK), `HTTP ${detail.status} 附件=${JSON.stringify(att.map(a => a.filename))}` ); } // ─── D. 权限面板 ────────────────────────────────────────────────── // 上面那封信会让 Agent 触发一次真实授权请求。等它出现(**按唯一标记**定位, // 待决列表里有历史积压,用「第一条新的」会拿到别人的)。 console.log(' 等 Agent 发起授权请求…'); let pending = null; for (let i = 0; i < 90; i++) { const r = await api('/permission/pending'); const reqs = r.body?.requests || []; pending = reqs.find(q => JSON.stringify(q).includes(MARK)); if (pending) break; await new Promise(r => setTimeout(r, 2000)); } chk('D. 出现了属于本次实验的授权请求', !!pending, pending ? `mail=${String(pending.mail_id).slice(0, 8)}` : '90 次轮询未等到'); if (pending) { // 界面侧:切到「授权」面板,确认它把这个请求列出来了 await page.locator('button:has-text("授权")').first().click(); await page.waitForTimeout(1500); // 刷新一次列表,避免依赖 SSE 是否已推送 await page.evaluate(() => window.dispatchEvent(new Event('focus'))); await page.waitForTimeout(1500); const panelText = await page.locator('body').innerText(); chk('D. 授权面板里列出了它', panelText.includes(MARK.slice(0, 20)), '按标记片段找'); await shot('30-permissions'); // 点开那条请求,在详情里点「同意」 const row = page.locator(`text=${MARK.slice(0, 16)}`).first(); if (await row.count()) { await row.click(); await page.waitForTimeout(1500); } const agree = page.locator('button:has-text("同意")').first(); const hasAgree = (await agree.count()) > 0; chk('D. 详情里出现了「同意」按钮', hasAgree); if (hasAgree) { await agree.click(); await page.waitForTimeout(2500); await shot('31-decided'); } // 外部核验 1:决策被记录,且**确实来自这次界面点击** let decided = null; for (let i = 0; i < 20; i++) { const r = await api('/permission/pending'); const still = (r.body?.requests || []).find(q => q.mail_id === pending.mail_id); if (!still) { decided = 'gone-from-pending'; break; } await new Promise(r2 => setTimeout(r2, 1000)); } chk('D. 外部核验:请求已离开待决列表', !!decided, decided || ''); // 外部核验 2(最关键):Agent 真的执行了 —— 文件出现且内容等于标记 const markerPath = join(TMP, `${MARK}.txt`); let content = null; for (let i = 0; i < 60; i++) { if (existsSync(markerPath)) { content = readFileSync(markerPath, 'utf8').trim(); break; } await new Promise(r => setTimeout(r, 2000)); } chk( 'D. ★ 外部核验:Agent 真的执行了(标记文件出现)', content === MARK, content === null ? `${markerPath} 不存在(界面点了同意,但没走到 Agent)` : JSON.stringify(content) ); } } catch (e) { console.log(`\n 异常:${String(e).slice(0, 300)}`); failed.push('脚本异常'); await shot('99-error'); } finally { if (consoleErrors.length) { console.log('\n 控制台错误(去重后前 5 条):'); for (const t of [...new Set(consoleErrors)].slice(0, 5)) console.log(' ', t); } try { await browser.close(); } catch { /* 断连无妨 */ } if (child) { // 等它真的退出:只发信号不等,下次跑会撞上旧窗口(同 SSE 那次的教训) child.kill('SIGTERM'); for (let i = 0; i < 20 && child.exitCode === null; i++) await new Promise(r => setTimeout(r, 200)); if (child.exitCode === null) child.kill('SIGKILL'); } } console.log(`\n 结果:${failed.length === 0 ? '全部通过' : `${failed.length} 项失败`}`); for (const f of failed) console.log(` ✗ ${f}`); console.log(` 截图:${TMP}`); process.exit(failed.length === 0 ? 0 : 1);