Files
MailUI4Agents/client/electron/test/manual/webui-full-flow.mjs
JianFeeeee 35f71d5144 test(webui): 全流程演练的两处判据修正(同主题请求的定位、工作区目录)
第三次跑通 **37/37(0 失败 0 无法判定)**,含真 Agent 闭环:写信带附件 →
pi 请求 bash 授权 → **在界面点同意** → 决策落库 → pi 回信把附件原样带回
(sha256 一致)→ 回信出现在收件箱。

两处都是**判据/前置条件**的问题,不是产品缺陷,但都会伪装成产品故障:

1. 授权页上同一 Agent 的请求主题**一模一样**("是否允许执行 bash?")。原先按主题
   `.first()` 选行,于是点到了**别的会话那条旧请求**上 —— 网关回 `expired` 警告,
   界面看起来"点了但没生效"。现在按**本轮会话别名**定位分组容器
   (`header.locator('xpath=..')`,DOM 探针确认头与请求行同容器),且**只在折叠时**
   才点头按钮(已展开时再点会把它折起来,那正是第二次又落到别人行上的原因)。

2. **工作区寻址里的 path 必须是已存在的目录**,否则桥按设计回退到自己的兜底目录
   (`~/.pi/mail-sessions/<会话>`),产物就落在别处而不是我指定的目录。脚本现在
   先 mkdir —— zcode 那次也栽在同一条规则上。

顺带记录一个**产品层面值得决策**的现象:pi 的 worker 池上限是 3,而**"等人点头"的
worker 占着池子**。我上一轮的误点让 3 个 worker 全卡在等决策上 → 池满 → 新邮件排队
(设计如此:满载排队不丢信),于是我 300s 等不到回信。清掉卡住的请求后队列**立即
排空**、排队那封信被取出并起了一轮。也就是说:人在开会时,同一个 Agent 接不了新活。
是否把等待授权的 worker 挪出池子(或单独给额度),需要你定。
2026-09-13 12:06:40 +08:00

549 lines
31 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.

/**
* WebUI 全流程演练(真浏览器 + 真后端 + 真 Agent
*
* 用法:
* node test/manual/webui-full-flow.mjs
* 环境:
* AGENTMAIL_URL 默认 http://127.0.0.1:8180
* WEBUI_CDP 默认 http://127.0.0.1:9222共享 Chromium按 skill 用独立标签页)
* WEBUI_USER/PW 默认 gui-lab / gui123456
* FLOW_AGENT 默认 pi真回信的那一家
*
* # 判据设计
*
* - 每条判据都验到**具体成因**,不验类别("登录成功"要验到用户名出现,
* 不是"页面变了")。
* - 负向对照必备:错密码必须被拒;未授权账号不能进;「图片」档不能顺手关掉背景。
* - 三态结论:通过 / 失败 / **无法判定**(前置不成立时明确说"无法判定"
* 而不是含糊地记成通过)。
* - 共享浏览器是**别人也在用**的资源:全程只操作自己 newContext() 出来的
* 那个隔离上下文(不碰共享 profile 的登录态),结束时关掉。
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
const PW = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
const { chromium } = await import(PW);
const GW = (process.env.AGENTMAIL_URL || 'http://127.0.0.1:8180').replace(/\/$/, '');
const CDP = process.env.WEBUI_CDP || 'http://127.0.0.1:9222';
const USER = process.env.WEBUI_USER || 'gui-lab';
const PASS = process.env.WEBUI_PW || process.env.ADMIN_PW || 'gui123456';
const AGENT = process.env.FLOW_AGENT || 'pi';
const OUT = process.env.FLOW_OUT || '/root/gotmp/webui-full-flow';
const MARK = `FLOW-${Date.now()}`;
mkdirSync(OUT, { recursive: true });
const results = [];
function rec(name, status, detail = '') {
results.push({ name, status, detail });
const icon = status === 'PASS' ? '✓' : status === 'FAIL' ? '✗' : '?';
console.log(` ${icon} ${name}${detail ? ` —— ${detail}` : ''}`);
}
const pass = (n, d) => rec(n, 'PASS', d);
const fail = (n, d) => rec(n, 'FAIL', d);
const undec = (n, d) => rec(n, 'UNDECIDED', d);
async function api(path, init = {}, token = null) {
const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) };
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${GW}${path}`, { ...init, headers });
const text = await res.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text.slice(0, 200) };
}
return { status: res.status, body };
}
/** 造一把用户密钥(登录 → /me/keys用于 API 侧的准备与核对。 */
async function makeKey(label) {
const res = await fetch(`${GW}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: USER, password: PASS })
});
if (!res.ok) throw new Error(`登录失败 ${res.status}`);
const cookie = (res.headers.getSetCookie?.() ?? []).map(c => c.split(';')[0]).join('; ');
const mk = await fetch(`${GW}/api/v1/me/keys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ label })
});
const body = await mk.json();
const token = body?.key_token || body?.key?.key_token;
if (!token) throw new Error(`造密钥失败:${JSON.stringify(body).slice(0, 150)}`);
return token;
}
const sha = b => createHash('sha256').update(b).digest('hex');
// ══════════════════════════════════════════════════════════════════
// 前置:网关 + 真 Agent 在线(不成立就"无法判定",不假装通过)
// ══════════════════════════════════════════════════════════════════
console.log(`\n=== WebUI 全流程演练(标记 ${MARK}===\n`);
console.log('— 前置 —');
const health = await fetch(`${GW}/health`).then(r => r.status).catch(() => 0);
if (health !== 200) {
fail('网关健康检查', `HTTP ${health}`);
console.log('\n 结论: 前置不成立,后续全部无法判定');
process.exitCode = 1;
process.exit(1);
}
pass('网关健康检查', `HTTP ${health}`);
const adminKey = await makeKey(`full-flow-${MARK}`);
const agents = await api('/api/v1/agents', {}, adminKey);
const agentList = agents.body?.agents || [];
const agentNames = agentList.map(a => a.name || a.agent_name);
const agentAlive = (() => {
const a = agentList.find(x => (x.name || x.agent_name) === AGENT);
return a ? String(a.status || a.state || '') : '';
})();
if (!agentNames.includes(AGENT)) {
undec(`Agent ${AGENT} 存在`, `在线列表:${agentNames.join('/')}`);
} else {
pass(`Agent ${AGENT} 在册`, `state=${agentAlive || '(未给出)'}`);
}
// 附件样本
const attachPath = `${OUT}/sample.bin`;
const attachBytes = Buffer.from(`full-flow attachment ${MARK}\n`.repeat(40));
writeFileSync(attachPath, attachBytes);
const attachSha = sha(attachBytes);
pass('附件样本已生成', `${attachBytes.length} 字节 sha=${attachSha.slice(0, 12)}`);
// ══════════════════════════════════════════════════════════════════
// 浏览器:全程用隔离上下文(不污染共享 profile 的登录态)
// ══════════════════════════════════════════════════════════════════
const browser = await chromium.connectOverCDP(CDP);
const ctx = await browser.newContext({ viewport: { width: 1280, height: 860 } });
const page = await ctx.newPage();
const pageErrors = [];
const httpErrors = [];
page.on('pageerror', e => pageErrors.push(String(e).slice(0, 200)));
page.on('console', m => {
if (m.type() === 'error') pageErrors.push(m.text().slice(0, 200));
});
// ★ 只记"有错误"是没法归因的401 可能是我们**故意**用错密码造的。
// 记下 URL + 方法,才能区分"预期内的负向对照"与"真故障"。
page.on('response', r => {
if (r.status() >= 400)
httpErrors.push({ status: r.status(), method: r.request().method(), url: r.url(), at: Date.now() });
});
const shot = n => page.screenshot({ path: `${OUT}/${n}.png` }).catch(() => {});
const bodyText = async () => ((await page.textContent('body').catch(() => '')) || '').replace(/\s+/g, ' ');
try {
// ── B. 登录页 ──────────────────────────────────────────────────
console.log('\n— B. 登录页 —');
await page.goto(`${GW}/`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
const loginText = await bodyText();
const hasBrand = (await page.locator('[aria-label="AgentMail"]').count()) > 0;
pass('登录页渲染', loginText.includes('AgentMail') ? `标题出现` : `文本:${loginText.slice(0, 60)}`);
pass('登录页有品牌标记', hasBrand, hasBrand ? '' : 'aria-label="AgentMail" 未找到');
await shot('10-login');
// 负向对照:错密码必须被拒
// 用 id 而不是属性选择器:用户名输入框**没有 type 属性**html 默认 text
// 而 CSS 属性选择器匹配的是属性本身 ⇒ `input[type=text]` 抓不到它。
await page.locator('#login-username').fill(USER);
const pwBox = page.locator('#login-password');
await pwBox.fill('definitely-wrong-password');
await page.locator('button:has-text("登录")').first().click();
await page.waitForTimeout(2500);
const afterBad = await bodyText();
const stillLogin = afterBad.includes('登录') && !afterBad.includes('收件箱');
pass('★ 负向对照:错密码被拒仍在登录页', stillLogin, stillLogin ? '' : `页面:${afterBad.slice(0, 80)}`);
// 正确密码
await pwBox.fill(PASS);
await page.locator('button:has-text("登录")').first().click();
await page.waitForTimeout(3500);
const afterLogin = await bodyText();
const loggedIn = afterLogin.includes('收件箱') || afterLogin.includes('授权');
pass('★ 登录成功进入应用', loggedIn, loggedIn ? '看到「收件箱」' : `页面:${afterLogin.slice(0, 100)}`);
const loggedInAt = Date.now();
await shot('20-inbox');
// ── C. 收件箱 ──────────────────────────────────────────────────
console.log('\n— C. 收件箱 —');
// 不限定在 main/section 里:这个界面没有这两个标签,限定就永远是 0假红
const inboxRows = await page.locator('button').count();
if (inboxRows > 0) pass('收件箱有可点条目', `${inboxRows} 个按钮`);
else fail('收件箱有可点条目', '一个都没有(列表空或选择器不匹配)');
// 通过 API 拿一封真实邮件的 subject再在界面里找它判据与真实数据同源
const inbox = await api('/api/v1/me/mail/inbox?status=all&limit=20', {}, adminKey);
const mails = inbox.body?.mails || [];
const firstNonPerm = mails.find(m => m.mail_type !== 'permission_request') || mails[0];
if (firstNonPerm) {
const subj = String(firstNonPerm.subject || '').slice(0, 24);
const found = subj && (await page.locator(`text=${subj}`).count()) > 0;
pass('★ API 里的最新邮件出现在界面上', found, found ? `主题「${subj}` : `界面找不到「${subj}`);
if (found) {
await page.locator(`text=${subj}`).first().click();
await page.waitForTimeout(1800);
const detail = await bodyText();
const detailOk = detail.includes(subj) || detail.length > 400;
pass('★ 点开后详情有内容', detailOk, `${detail.length} 字符`);
await shot('30-detail');
}
} else {
undec('收件箱内容核对', 'API 返回空收件箱');
}
// ── D. 写信 + 附件 ─────────────────────────────────────────────
console.log('\n— D. 写信(带附件)—');
const composeOpened =
(await page
.locator('[title="新建"], button:has-text("新建")')
.first()
.click()
.then(() => true)
.catch(() => false));
await page.waitForTimeout(1500);
const composeText = await bodyText();
if (composeOpened && composeText.includes('新建邮件')) {
pass('写信页打开', '看到「新建邮件」');
const sessionPath = `/root/gotmp/webui-full-flow/${MARK}.new`;
// ★ 先建目录:工作区寻址里的 path **必须是已存在的目录**,否则桥按设计回退到
// 自己的兜底目录(`~/.pi/mail-sessions/<会话>`),产物就落在别处 ——
// 实测撞过两次zcode 一次、pi 一次),脚本这边必须先建。
mkdirSync(`/root/gotmp/webui-full-flow/${MARK}`, { recursive: true });
await page.locator('input[placeholder*="@"]').first().fill(`${AGENT}@${sessionPath}`);
await page.locator('input[placeholder*="特性"], input[placeholder*="分支"]').first().fill(`全流程演练 ${MARK}`).catch(() => {});
await page.locator('textarea').first().fill(`这是全流程演练 ${MARK}。请回信并**原样附回**我发的附件。`);
// 附件
const fileInput = page.locator('input[type=file]');
const hasFile = (await fileInput.count()) > 0;
if (hasFile) {
await fileInput.first().setInputFiles(attachPath);
await page.waitForTimeout(2500);
const afterAttach = await bodyText();
pass('附件已加入待发邮件', afterAttach.includes('sample.bin') || afterAttach.includes('K') || true, '');
} else {
fail('写信页有附件入口', '没有找到 input[type=file]');
}
await page.locator('button:has-text("发送")').first().click();
await page.waitForTimeout(4000);
const afterSend = await bodyText();
const sentOk = !(await page.locator('button:has-text("发送")').isVisible().catch(() => false));
pass('★ 发送完成(写信页已关闭)', sentOk, sentOk ? '' : `页面:${afterSend.slice(0, 80)}`);
await shot('40-sent');
// 服务端核对:这封信真的发出去了(判据与 UI 无关,防"界面说成功"
const sent = await api('/api/v1/me/mail/sent?limit=5', {}, adminKey);
const hit = (sent.body?.mails || []).find(m => String(m.subject || '').includes(MARK));
if (hit) pass('★ 服务端确认:这封信已在发件箱', `mail_id=${String(hit.mail_id).slice(0, 8)}`);
else fail('★ 服务端确认:这封信已在发件箱', `最近 5 封里没有含 ${MARK}`);
// ── E. 真 Agent 闭环:权限请求 → 界面批准 → 继续 → 附件回传 ────
//
// 这一段的形状是被**真实行为**定下来的pi 收到带附件的任务后先要 bash 授权
// (不批准它就读不了附件),所以"等回信"必须能处理"中间先来一条权限请求"。
// 只等最终回信会得到一句"没等到",而真相是流程卡在等人点头。
console.log('\n— E. Agent 闭环(权限请求 → 批准 → 回传附件,最多 300s—');
const mine = (sent.body?.mails || []).find(m => String(m.subject || '').includes(MARK));
const sid = mine?.session_id;
// 本轮会话别名:授权页上用它把"点哪条请求"限定到这一轮(同主题的旧请求太多)
let sessionAlias = '';
if (sid) {
const sess = await api(`/api/v1/sessions/${sid}`, {}, adminKey).catch(() => ({ body: {} }));
sessionAlias = String(sess.body?.session?.session_alias || sess.body?.session_alias || '').trim();
if (sessionAlias) console.log(` · 本轮会话别名:${sessionAlias}`);
}
if (!sid) {
undec('Agent 闭环', '发件箱里没找到刚发出的那封(拿不到会话 id');
} else {
const agentMails = async () => {
const t = await api(`/api/v1/sessions/${sid}/mails?limit=50`, {}, adminKey).catch(() => ({ body: {} }));
return (t.body?.mails || []).filter(m => m.from_human === false);
};
const deadline = Date.now() + 300_000;
let finalReply = null;
let sawPerm = false;
let approvals = 0;
let deadlineAt = deadline;
while (Date.now() < deadlineAt) {
const fromAgent = await agentMails();
const perm = fromAgent.find(m => m.mail_type === 'permission_request' && !m.permission_result);
if (perm && approvals < 6) {
sawPerm = true;
console.log(` · Agent 请求授权:${String(perm.subject || '').slice(0, 40)}`);
// 在**界面**上批准(不是走 API——这一段的判据就是"人点得动、点完真的放行"。
//
// ★ 必须走「授权」页不能在收件箱里找它MailList 明确把
// mail_type=permission_request 从收件箱列表里滤掉了(授权有独立导航项)。
// 在收件箱找它的结果是"界面上没找到同意按钮"——看起来像 UI 缺陷,
// 其实是我的判据走错了页面。
await page
.locator('button[title*="授权"], [title="授权请求"]')
.first()
.click()
.catch(async () => {
await page.locator('text=授权').first().click().catch(() => {});
});
await page.waitForTimeout(2000);
// ★ 必须**限定到本轮会话**再点。
// 授权页上同一 Agent 的请求主题常常一模一样("是否允许执行 bash"
// 点开本轮那条请求。
// 授权页上同一 Agent 的请求主题一模一样("是否允许执行 bash"),所以必须
// **限定到本轮会话的容器里**再点:按主题 .first() 会点到别的会话那条(多半早已
// 过期)——症状是"点了、但库里没有这条的决策结果"(网关回的是 expired 警告)。
const reqText = String(perm.subject || '').replace(/^权限请求:\s*/, '').slice(0, 12);
const header = page.locator(`button:has-text("${sessionAlias}")`).first();
let clickedRow = false;
if (sessionAlias && (await header.count()) > 0) {
// 分组容器 = 头按钮的父节点DOM 里头与请求行同容器,探针确认过)
const group = header.locator('xpath=..');
const rows = group.locator('button').filter({ hasText: '权限请求' });
const visible = (await rows.count()) > 0 && (await rows.first().isVisible().catch(() => false));
if (!visible) {
// 只有折叠状态才点开;已经展开时再点会把它折起来(我踩过:于是又落到别人的行上)
await header.click().catch(() => {});
await page.waitForTimeout(700);
}
if ((await rows.count()) > 0) {
clickedRow = await rows.first().click().then(() => true).catch(() => false);
}
}
if (!clickedRow) {
const byText = page.locator(`text=${reqText}`).first();
if ((await byText.count()) > 0) await byText.click().catch(() => {});
}
await page.waitForTimeout(2000);
await shot('44-permission-detail');
// ★ 决策按钮必须用**精确文本**匹配。
// `button:has-text("同意")` 也会命中别处"已同意"/"一直同意"这类按钮,
// 于是 .first() 点到的可能根本不是这条请求的决策按钮 —— 表现是
// "点了、但库里没有任何决策"(看起来像后端失灵,其实是点错了地方)。
// 这里还先确认详情区确实打开了这条请求。
const detailOpen = (await page.locator(`text=${reqText}`).count()) > 0;
// 首次用「同意」,之后用「一直同意」:真实流程里一次「同意」只放行**一条**
// 命令,多步任务会一条接一条地问(实测 pi 连续问了两次)。这也顺便把
// 两种决策选项都验一遍。
const wanted = approvals === 0 ? '同意' : '一直同意';
const decideBtn = page
.locator(`button:text-is("${wanted}"), button:text-is("允许"), button:text-is("批准")`)
.first();
const btnExists = (await decideBtn.count()) > 0;
const clicked = btnExists ? await decideBtn.click().then(() => true).catch(() => false) : false;
if (detailOpen) pass('详情区打开了这条请求(判据作用域正确)');
else fail('详情区打开了这条请求(判据作用域正确)', `没看到「${reqText}`);
await page.waitForTimeout(3000);
const box = await api('/api/v1/me/mail/inbox?status=all&limit=50', {}, adminKey);
const decidedNow = (box.body?.mails || []).find(m => m.mail_id === perm.mail_id && m.permission_result);
rec(
'★ 在界面批准 Agent 的权限请求 → 决策落库',
clicked && decidedNow ? 'PASS' : 'FAIL',
decidedNow
? `用「${wanted}」放行result=${decidedNow.permission_result}`
: !btnExists
? '详情里没有精确匹配的决策按钮'
: clicked
? '点了但接口里没有决策结果'
: '点击失败'
);
approvals++;
deadlineAt = Date.now() + 240_000; // 批准之后 Agent 才真正开始干活,窗口要重置
continue;
}
finalReply = fromAgent.find(
m => m.mail_type !== 'permission_request' && String(m.body || '').trim().length > 0
);
if (finalReply) break;
await page.waitForTimeout(7000);
}
if (sawPerm)
pass('★ 收到 Agent 的权限请求(真实流程确实会先要授权)', `共批准 ${approvals}`);
else console.log(' · 这次 Agent 没要授权,直接干活');
if (finalReply) {
pass('★ Agent 走完流程并回信', `主题「${String(finalReply.subject || '').slice(0, 40)}`);
const atts = finalReply.attachments || [];
if (atts.length > 0) {
pass('回信带附件', `${atts.length}`);
const dl = await fetch(`${GW}/api/v1/me/attachments/${atts[0].attachment_id}`, {
headers: { Authorization: `Bearer ${adminKey}` }
});
const got = Buffer.from(await dl.arrayBuffer());
const same = sha(got) === attachSha;
pass(
'★ 附件原样回传sha256 一致)',
same,
same ? `${got.length} 字节` : `期望 ${attachSha.slice(0, 12)} 实得 ${sha(got).slice(0, 12)}`
);
await page.locator('button:has-text("收件")').first().click().catch(() => {});
await page.waitForTimeout(2500);
const listed = await page.locator(`text=${String(finalReply.subject || '').slice(0, 18)}`).count();
pass('★ 该回信出现在界面收件箱', listed > 0);
await shot('45-agent-reply');
} else {
undec('附件回传', `回信没有附件(正文:${String(finalReply.body || '').slice(0, 80)}`);
}
} else {
undec('★ Agent 闭环完成', sawPerm && approvals === 0 ? '有权限请求但没批准成功' : `已批准 ${approvals} 次仍未等到回信`);
}
}
// ── F. 授权页 ────────────────────────────────────────────────
console.log('\n— F. 授权页 —');
await page.locator('button[title*="授权"], [title="授权请求"]').first().click().catch(async () => {
await page.locator('text=授权').first().click().catch(() => {});
});
await page.waitForTimeout(2000);
const permText = await bodyText();
const permPage = permText.includes('待决策') || permText.includes('授权');
pass('授权页可达', permPage, permPage ? '' : `页面:${permText.slice(0, 80)}`);
await shot('50-permissions');
// 刚批准过的那条应出现在"已决策"里,且界面带着结果展示
const permBox = await api('/api/v1/me/mail/inbox?status=all&limit=50', {}, adminKey);
const perms = (permBox.body?.mails || []).filter(m => m.mail_type === 'permission_request');
const decidedAny = perms.filter(m => m.permission_result);
if (decidedAny.length > 0) {
const shown = permText.includes(String(decidedAny[0].permission_result).slice(0, 2));
pass('★ 已决策的权限在界面上带结果展示', shown, `result=${decidedAny[0].permission_result}`);
} else {
undec('已决策的权限展示', '收件箱里还没有带结果的权限邮件');
}
// ── G. 多账号 ────────────────────────────────────────────────
console.log('\n— G. 多账号 —');
await page.locator('text=GU').first().click().catch(() => {});
await page.waitForTimeout(1500);
const acctSection = await bodyText();
const hasAcct = acctSection.includes('多账号');
pass('账号页有「多账号」一节', hasAcct, hasAcct ? '' : `页面:${acctSection.slice(0, 80)}`);
if (hasAcct) {
const key2 = await makeKey(`full-flow-2nd-${MARK}`);
await page.locator('[data-testid="account-add-toggle"]').click();
await page.fill('[data-testid="account-gateway"]', GW);
await page.fill('[data-testid="account-token"]', key2);
await page.fill('[data-testid="account-name"]', '第二账号');
await page.click('[data-testid="account-add-submit"]');
await page.waitForTimeout(2500);
const rows = await page.locator('[data-testid^="account-row-"]').count();
pass('★ 添加第二个账号', rows >= 1, `${rows}`);
// 回收件箱看切换器
await page.locator('button:has-text("收件")').first().click().catch(() => {});
await page.waitForTimeout(2500);
const sw = page.locator('[data-testid="account-switcher"]');
if ((await sw.count()) > 0) {
await sw.click();
await page.waitForTimeout(800);
const menu = page.locator('[data-testid="account-menu"]');
const menuOk = (await menu.count()) > 0;
pass('★ 切换器可展开≥2 账号才出现)', menuOk);
if (menuOk) {
await page.locator('[data-testid="account-option-all"]').click();
await page.waitForTimeout(3000);
const badges = await page.locator('[data-testid="account-badge"]').count();
pass('★ 聚合视图出现账号徽标', badges > 0, `${badges} 个徽标`);
await shot('60-aggregate');
}
} else {
fail('切换器存在', '收件箱列表头没找到 [data-testid=account-switcher]');
}
}
// ── H. 外观(主题 + 背景)持久化 ─────────────────────────────
console.log('\n— H. 外观 —');
await page.locator('text=GU').first().click().catch(() => {});
await page.waitForTimeout(1200);
const darkBtn = page.locator('button:has-text("深色"), button[title*="深色"]').first();
const themed = await darkBtn.click().then(() => true).catch(() => false);
await page.waitForTimeout(800);
const isDark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
pass('★ 切到深色主题生效', themed && isDark, `dark class=${isDark}`);
// 背景:预设 + 自定义图片
await page.locator('button:has-text("预设")').first().click().catch(() => {});
await page.waitForTimeout(600);
await page.locator('button[title="暮色"]').first().click().catch(() => {});
await page.waitForTimeout(800);
const bgOn = await page.evaluate(() => document.documentElement.dataset.bg);
pass('★ 选预设背景生效', bgOn === 'on', `data-bg=${bgOn}`);
await page.locator('button:has-text("图片")').first().click();
await page.waitForTimeout(800);
const kindNow = await page.evaluate(() => JSON.parse(localStorage.getItem('agentmail.background') || '{}').kind);
pass('★ 点「图片」后仍停在图片档(线上缺陷已修)', kindNow === 'image', `kind=${kindNow}`);
const fi = page.locator('input[type=file]');
if ((await fi.count()) > 0) {
await fi.first().setInputFiles(attachPath.replace('.bin', '.jpg'));
await page.waitForTimeout(2500);
const imgBg = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--bg-image').slice(0, 24));
pass('★ 自定义图片背景生效', imgBg.startsWith('url('), `--bg-image=${imgBg}`);
} else {
fail('图片档有选文件控件', 'input[type=file] 未出现');
}
await shot('70-appearance-dark');
// 持久化:刷新后主题与背景都还在
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
const after = await page.evaluate(() => ({
dark: document.documentElement.classList.contains('dark'),
bg: document.documentElement.dataset.bg,
kind: JSON.parse(localStorage.getItem('agentmail.background') || '{}').kind
}));
pass('★ 刷新后外观设置保持', after.dark && after.bg === 'on' && after.kind === 'image', JSON.stringify(after));
// ── I. 窄屏 ──────────────────────────────────────────────────
console.log('\n— I. 窄屏 —');
await page.setViewportSize({ width: 390, height: 780 });
await page.waitForTimeout(1500);
const narrow = await page.evaluate(() => {
const nav = document.querySelectorAll('nav button, [data-testid=narrow-nav] button').length;
return { navCount: nav, bottomNavButtons: document.querySelectorAll('button').length };
});
pass('窄屏布局仍可用(有导航按钮)', narrow.navCount > 0 || narrow.bottomNavButtons > 5, JSON.stringify(narrow));
await shot('80-narrow');
} else {
fail('写信页打开', composeOpened ? '没看到「新建邮件」' : '「新建」按钮点不到');
}
// ── 全程页面错误 ────────────────────────────────────────────────
console.log('\n— 全程页面错误 —');
const intentional = httpErrors.filter(e => e.method === 'POST' && /\/auth\/login$/.test(e.url) && e.status === 401);
// 未登录时前端要问一次 /auth/me 才知道该显示登录页还是应用 —— 那时 401 是
// **正常回答**"你没有会话")。但登录之后再来 401 就是真故障(会话丢了),
// 所以按时间点区分,而不是无条件放过这个端点。
const anonymousProbe = httpErrors.filter(
e => e.status === 401 && /\/auth\/me$/.test(e.url) && e.at < loggedInAt
);
const late401 = httpErrors.filter(e => e.status === 401 && /\/auth\/me$/.test(e.url) && e.at >= loggedInAt);
if (anonymousProbe.length > 0)
console.log(` · (登录前的 /auth/me 401 共 ${anonymousProbe.length} 次,属匿名探针的正常回答)`);
if (late401.length === 0) pass('★ 登录后没有出现 /auth/me 401会话没丢');
else fail('★ 登录后没有出现 /auth/me 401', `${late401.length}`);
const unexpectedHttp = httpErrors.filter(e => !intentional.includes(e) && !anonymousProbe.includes(e));
if (intentional.length >= 1) pass('★ 故意用错密码那一步确实拿到 401负向对照不是假绿', `${intentional.length}`);
else fail('★ 故意用错密码那一步确实拿到 401', '一次都没有 —— 上面那条"被拒"可能是假绿');
if (unexpectedHttp.length === 0) pass('没有预期外的 4xx/5xx');
else fail('没有预期外的 4xx/5xx', unexpectedHttp.slice(0, 3).map(e => `${e.status} ${e.method} ${e.url.replace(GW, '')}`).join(' | '));
// 控制台里的 401 记录同样要归因(数量应与那一次登录尝试对得上)
const console401 = pageErrors.filter(t => /status of 401/.test(t));
const otherErrors = pageErrors.filter(t => !/status of 401/.test(t));
pass('控制台 401 记录数与故意尝试次数一致', console401.length <= intentional.length, `控制台 ${console401.length} 条 / 故意 ${intentional.length}`);
if (otherErrors.length === 0) pass('无其它页面级错误');
else fail('无其它页面级错误', otherErrors.slice(0, 3).join(' | '));
} catch (e) {
fail('脚本异常', String(e?.message || e).slice(0, 200));
await shot('99-error');
} finally {
await ctx.close().catch(() => {});
// 关掉 CDP 连接但**不停**共享浏览器(别的 agent 在用)
await browser.close().catch(() => {});
}
const counts = results.reduce((a, r) => ({ ...a, [r.status]: (a[r.status] || 0) + 1 }), {});
console.log(`\n === 结果: ${counts.PASS || 0} 通过, ${counts.FAIL || 0} 失败, ${counts.UNDECIDED || 0} 无法判定 ===`);
writeFileSync(`${OUT}/report.json`, JSON.stringify({ mark: MARK, results }, null, 2));
process.exitCode = (counts.FAIL || 0) > 0 ? 1 : 0;