From f243355bd4820f4727ea5b23e743fe464c527fee Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 09:30:44 +0800 Subject: [PATCH] =?UTF-8?q?test(webui):=20=E5=85=A8=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E6=BC=94=E7=BB=83=EF=BC=88=E7=9C=9F=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=20+=20=E7=9C=9F=E5=90=8E=E7=AB=AF=20+=20=E7=9C=9F=20Agent=20?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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「无权归档他人的会话」——这是 对的,没绕过去)。 --- .../electron/test/manual/webui-full-flow.mjs | 515 ++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 client/electron/test/manual/webui-full-flow.mjs diff --git a/client/electron/test/manual/webui-full-flow.mjs b/client/electron/test/manual/webui-full-flow.mjs new file mode 100644 index 0000000..6016a1f --- /dev/null +++ b/client/electron/test/manual/webui-full-flow.mjs @@ -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;