test(webui): 全流程演练(真浏览器 + 真后端 + 真 Agent 闭环)
test/manual/webui-full-flow.mjs —— 37 项判据,实测 37/37 全绿、0 失败
0 无法判定。覆盖:登录(含错密码负向对照)→ 收件箱 → 打开详情 → 写信带
附件 → **真 Agent 闭环**(Agent 请求 bash 授权 → 在界面批准 → 决策落库 →
Agent 继续 → 回信把附件原样带回,sha256 一致)→ 授权页 → 多账号添加/切换
→ 主题与背景(含自定义图片)刷新后保持 → 窄屏 → 全程无预期外 4xx/5xx。
判据要点(这轮踩过的坑都在里面):
- 授权邮件**不在收件箱列表里**(MailList 明确把 permission_request 滤到
「授权」导航项)——在收件箱里找它必然"找不到同意按钮",看着像 UI 缺陷。
- 决策按钮必须用**精确文本**:`has-text("同意")` 会命中"已同意"/"一直同意",
`.first()` 可能点到别处,表现是"点了但库里没有任何决策"(像后端失灵)。
- 一次「同意」只放行**一条**命令,多步任务会一条接一条地问(实测 pi 连问
两次)——所以脚本首次用「同意」、之后用「一直同意」,两种选项都验到。
- 未登录时前端要问一次 `/auth/me`,那时 401 是**正常回答**;登录之后再 401
才是会话丢失。判据按时间点区分,而不是无条件放过这个端点。
- 故意用错密码造的 401 也要断言"确实产生了",否则"被拒"那条可能是假绿。
顺带清掉本轮留下的测试数据:6 条待决权限决策为拒绝(剩 0),13 个测试会话
归档 12 个(余下 1 个属他人会话,服务端 403「无权归档他人的会话」——这是
对的,没绕过去)。
This commit is contained in:
515
client/electron/test/manual/webui-full-flow.mjs
Normal file
515
client/electron/test/manual/webui-full-flow.mjs
Normal file
@ -0,0 +1,515 @@
|
|||||||
|
/**
|
||||||
|
* 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`;
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
const reqText = String(perm.subject || '').replace(/^权限请求:\s*/, '').slice(0, 12);
|
||||||
|
const rowById = page.locator(`text=${String(perm.subject || '').slice(0, 18)}`).first();
|
||||||
|
const rowByText = page.locator(`text=${reqText}`).first();
|
||||||
|
if ((await rowById.count()) > 0) await rowById.click().catch(() => {});
|
||||||
|
else await rowByText.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;
|
||||||
Reference in New Issue
Block a user