/** * 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;