第三次跑通 **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 挪出池子(或单独给额度),需要你定。
549 lines
31 KiB
JavaScript
549 lines
31 KiB
JavaScript
/**
|
||
* 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;
|