Files
MailUI4Agents/client/electron/test/manual/desktop-phase3-verify.mjs
JianFeeeee 8b2206ed53 fix(electron): Phase 3 验收抓到的两个静默缺陷 —— 白屏与登录
Phase 3(写信 + 附件 + 权限面板)的验收脚本第一次跑就把这两件事翻出来了,
两个都**表现正常**:进程活着、窗口标题对、接口能通,只有结果不对。

## 1. 打包后的应用是白屏(vite 的 base 缺省值)

`vite.config.ts` 没设 `base`,Vite 按默认的 `/` 生成 `src="/assets/index-xxx.js"`。
同一份 dist 有两个宿主:网关在 `/` 下伺服它(Web 正常),Electron 用 `loadFile()`
从 **file:///…/dist/index.html** 加载它 —— 绝对路径在那儿解析成
`file:///assets/index-xxx.js`(不存在),**JS 根本没加载**。

现场:`#root` 里一个子节点都没有。没有报错对话框,控制台里只有一条不起眼的
资源加载失败。而当时所有既有检查都是绿的:`npm run build` 成功、deb 元数据检查、
asar 内容清点(**它们只看文件在不在,不看文件引用什么**)。

修法:`base: './'` —— 两边都对(Web 在 /index.html 里 `./assets/x.js` → `/assets/x.js`;
Electron 在 dist/index.html 里 → `dist/assets/x.js`)。

## 2. 桌面壳用账号密码登录是断的,而且静默失败

账号密码登录靠 `SameSite=Lax` 的会话 Cookie,而桌面壳的页面是 `file://`
(**不透明源**)—— Chromium 按第三方上下文处理它,Cookie **不予存储**。

实测现场:`POST /auth/login` 返 **200**、响应体能读出用户名,但 `document.cookie`
是空的,紧接着的 `/auth/me` 返 **401**;界面停在登录页,看起来像「密码错了」,
而同样的账号密码用 curl 登录是成功的。所以这不是凭据问题。

修法:桌面壳里**不再给账号密码表**(一个必然失败的按钮比没有更糟),改成粘贴
**用户密钥**(`Authorization: Bearer`,桌面端本来就该这么用):
- preload 显式声明 `__AGENTMAIL_SHELL__ = 'desktop'`(宿主契约,而不是让渲染层
  sniff 协议;顺带让 jsdom 里可测 —— 那里的 `location.protocol` 不可重写)
- 新增 `authStore.loginWithKey`:成功后才留下令牌,失败**还原**(否则之后每个请求
  都会带上这个坏 key 并 401,而人看到的是「重输一次也不行」)
- 顺手修了 label 与 input 没有关联(`htmlFor`/`id`)—— 无障碍缺陷,也让测试能按标签查

## 验收

- 结构性守卫进 `npm test`(`test/packaging.test.mjs`,不需要浏览器):base 必须是
  相对路径、产物里不能有绝对资源引用、**安装包里的 dist 与当前构建一致**
  (前端改了没重打包时,装上去的人看到的是旧界面,两边不一致却谁都不报错)。
  判据自检过:把 base 改回 `/` 或把产物改回绝对路径,各自都能让对应那条变红。
- 组件测试 6 条(两种壳的形态、密钥成功/失败、空密钥不可提交)。
- `test/manual/desktop-phase3-verify.mjs`:真起打包好的应用(xvfb + CDP),
  一条贯穿的链 —— 用桌面 UI 写信带附件 → 外部核验信与附件真到了网关 →
  这封信触发 zcode 的真实授权请求 → 在桌面**授权面板**里点同意 →
  外部核验 **Agent 真的执行了**(标记文件出现)。第二次跑 14/14 全绿。
- 客户端全量 222/222;网关换新产物后 Web 依旧正常(相对路径在 `/` 下同样成立,
  实测渲染出收件箱、无控制台错误),并真发一封邮件确认回信到达。

## 判据自己的错(记一笔)

第一次跑时「附件真的挂在信上」报红,而库里那 41 字节的附件**明明挂在信上** ——
我把端点写成了 `/me/mail/{id}`(不存在,404),正确是 `/mail/{id}`。
判据用错端点时以「附件是空的」现形,看起来像功能 bug。

另:`pkill -f 'agentmail-web'` 会把**自己这条命令**也杀掉(命令行里含同样的字符串),
表现是「脚本没有任何输出、退出码 143」。改用端口定位(`ss -tlnp | grep :9223`)。
2026-09-12 20:25:00 +08:00

381 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.

/**
* 桌面客户端 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.tsxfile:// 是不透明源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);