/** * 多账号验收(真起打包产物 + 两个真实账号)。 * * 用法: * DESKTOP_BIN=release/linux-unpacked/agentmail-desktop \ * ACCT2_KEY_FILE=/root/gotmp/verify-l2-token.txt \ * node test/manual/multi-account-verify.mjs * * 环境: * DESKTOP_BIN 打包产物可执行文件(给了就由本脚本起:xvfb + CDP) * DESKTOP_CDP CDP 端点,默认 http://127.0.0.1:9224(避开 Phase 3 的 9223) * AGENTMAIL_URL Gateway,默认 http://127.0.0.1:8180 * ACCT1_USER/ACCT1_PW 第一个账号(默认 gui-lab / 从 ADMIN_PW 或 /root/gotmp/gui-lab-pw.txt 读) * ACCT2_KEY_FILE 第二个账号的用户密钥文件(默认 /root/gotmp/verify-l2-token.txt) * * # 判据为什么落在**网络层**而不是"列表里有多少行" * * 收件箱是按会话分组的、默认折叠,DOM 里的行数 ≠ 邮件数。所以"聚合了没有" * 用一个不会被分组干扰的判据:**聚合模式下每个可用账号各发一次收件箱请求, * 且各带自己的令牌**(抓请求头即可确认)。徽标的有无则是单/聚合视图的 * 确定性差异 —— 单账号视图不该有徽标。 */ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, rmSync } from 'node:fs'; import { join, dirname } from 'node:path'; const PW = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs'; const { chromium } = await import(PW); const { spawn } = await import('node:child_process'); const CDP = process.env.DESKTOP_CDP || 'http://127.0.0.1:9224'; const GW = (process.env.AGENTMAIL_URL || 'http://127.0.0.1:8180').replace(/\/$/, ''); const BIN = process.env.DESKTOP_BIN || ''; const TMP = process.env.TMPDIR_REAL || '/root/gotmp/multi-account'; const USERDATA = join(TMP, 'userdata'); const ACCT1_USER = process.env.ACCT1_USER || 'gui-lab'; const ACCT1_PW = process.env.ACCT1_PW || (existsSync('/root/gotmp/gui-lab-pw.txt') ? readFileSync('/root/gotmp/gui-lab-pw.txt', 'utf8').trim() : 'gui123456'); const ACCT2_KEY_FILE = process.env.ACCT2_KEY_FILE || '/root/gotmp/verify-l2-token.txt'; let pass = 0; let fail = 0; function check(name, ok, detail = '') { if (ok) { pass++; console.log(` ✓ ${name}`); } else { fail++; console.log(` ✗ ${name}${detail ? ` —— ${detail}` : ''}`); } } // ─── 准备两个账号的密钥 ─────────────────────────────────────────────── async function json(path, init = {}) { const res = await fetch(`${GW}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...(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 }; } async function loginCookie() { const r = await json('/api/v1/auth/login', { method: 'POST', body: JSON.stringify({ username: ACCT1_USER, password: ACCT1_PW }) }); if (r.status !== 200) throw new Error(`登录失败 ${r.status}:${JSON.stringify(r.body).slice(0, 120)}`); const raw = r.body?.session_token || r.body?.token || r.body?.data?.session_token; return raw ? `agentmail_session=${raw}` : (r.headers || '').toString(); } /** 用会话 cookie 造一把新的用户密钥(每次跑都新造,避免和线上既有密钥混在一起)。 */ async function makeKeyForAcct1() { // 会话 cookie 直接取 Set-Cookie const res = await fetch(`${GW}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: ACCT1_USER, password: ACCT1_PW }) }); if (!res.ok) throw new Error(`登录失败 ${res.status}`); const sc = res.headers.getSetCookie?.() ?? []; const cookie = sc.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: `multi-account-e2e-${Date.now()}` }) }); 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 key1 = await makeKeyForAcct1(); const key2 = existsSync(ACCT2_KEY_FILE) ? readFileSync(ACCT2_KEY_FILE, 'utf8').trim() : ''; if (!key2) { console.error(`缺少第二个账号的密钥文件:${ACCT2_KEY_FILE}`); process.exit(2); } // 服务端的期望值(与前端同一批端点、同样的参数) async function serverInbox(key) { const r = await json('/api/v1/me/mail/inbox?status=all&limit=50', { headers: { Authorization: `Bearer ${key}` } }); return (r.body?.mails || []).map(m => m.mail_id); } const ids1 = await serverInbox(key1); const ids2 = await serverInbox(key2); const expectedMerged = new Set([...ids1, ...ids2]).size; console.log(`\n服务端:账号1 ${ids1.length} 封、账号2 ${ids2.length} 封,去重后 ${expectedMerged} 封`); // ─── 预置 accounts.json 并起应用 ────────────────────────────────────── rmSync(TMP, { recursive: true, force: true }); mkdirSync(USERDATA, { recursive: true }); const accountsFile = join(USERDATA, 'accounts.json'); const seed = { version: 1, accounts: [ { id: '11111111-1111-4111-8111-111111111111', displayName: '甲账号', gateway: GW, token: key1, username: ACCT1_USER, lastUsed: '2026-09-12T01:00:00Z' }, { id: '22222222-2222-4222-8222-222222222222', displayName: '乙账号', gateway: GW, token: key2, username: 'jianf', lastUsed: '2026-09-12T00:00:00Z' } ] }; writeFileSync(accountsFile, JSON.stringify(seed, null, 2), { mode: 0o600 }); // 第二个账号的真实收件箱也取一次(用于"切换后只显示这个账号"的判据) const gate = spawn( 'xvfb-run', ['-a', '-s', '-screen 0 1400x900x24', BIN, '--no-sandbox', '--disable-gpu', `--user-data-dir=${USERDATA}`, `--remote-debugging-port=${new URL(CDP).port}`], { env: { ...process.env, AGENTMAIL_GATEWAY_URL: GW, DBUS_SESSION_BUS_ADDRESS: 'disabled:' }, stdio: ['ignore', 'pipe', 'pipe'] } ); gate.stdout.on('data', () => {}); gate.stderr.on('data', () => {}); async function waitCdp() { for (let i = 0; i < 60; i++) { try { const r = await fetch(`${CDP}/json/version`); if (r.ok) return true; } catch { /* 还没起来 */ } await new Promise(r => setTimeout(r, 500)); } return false; } let browser = null; try { if (!(await waitCdp())) throw new Error(`CDP 不可用:${CDP}`); browser = await chromium.connectOverCDP(CDP); const ctx = browser.contexts()[0] ?? (await browser.newContext()); const page = ctx.pages().find(p => p.url().startsWith('file:') || p.url().includes('index.html')) ?? ctx.pages()[0]; if (!page) throw new Error('没找到应用窗口'); await page.setViewportSize({ width: 1280, height: 820 }); const consoleErrors = []; page.on('pageerror', e => consoleErrors.push(String(e).slice(0, 200))); page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 200)); }); // 抓收件箱请求(含 Authorization 头) const inboxReqs = []; page.on('request', r => { const u = r.url(); if (!u.includes('/me/mail/inbox')) return; inboxReqs.push({ url: u, auth: r.headers()['authorization'] || '' }); }); console.log('\n=== 多账号验收 ===\n'); // ─── A. 启动就绪 ────────────────────────────────────────────────── await page.waitForTimeout(2500); mkdirSync(TMP, { recursive: true }); await page.screenshot({ path: join(TMP, '10-boot.png') }).catch(() => {}); const boot = await page.evaluate(() => ({ root: document.getElementById('root')?.children.length ?? -1, hasSwitcher: !!document.querySelector('[data-testid="account-switcher"]') })); check('应用渲染出内容(非白屏)', boot.root > 0, `root children=${boot.root}`); check('★ 顶栏出现账号选择器', boot.hasSwitcher); // 预置里甲账号 lastUsed 更新 → 默认选中它,标签应是它的显示名 const label = await page.textContent('[data-testid="account-switcher"]').catch(() => ''); check('默认选中最近使用的账号(甲账号)', (label || '').includes('甲账号'), `实际「${label}」`); // ─── B. 聚合:每账号各一次请求、各带自己的令牌 ──────────────────── inboxReqs.length = 0; await page.click('[data-testid="account-switcher"]'); await page.click('[data-testid="account-option-all"]'); await page.waitForTimeout(2000); await page.screenshot({ path: join(TMP, '20-aggregate.png') }).catch(() => {}); const aggReqs = inboxReqs.filter(r => r.url.includes('status=all')); const aggTokens = new Set(aggReqs.map(r => r.auth.replace(/^Bearer\s+/, ''))); check( '★ 聚合:每个可用账号各发一次收件箱请求', aggReqs.length >= 2, `实际 ${aggReqs.length} 次` ); check('★ 聚合:两次请求各带**自己的**令牌(没有串号)', aggTokens.size >= 2, `去重后 ${aggTokens.size} 个令牌`); const badges = await page.$$eval('[data-testid="account-badge"]', els => els.map(e => e.textContent.trim())); check('★ 聚合:列表出现账号归属徽标', badges.length > 0, `徽标 ${badges.length} 个`); check( '★ 徽标覆盖两个账号(两边都有邮件进列表)', new Set(badges).size >= 2, `去重后 ${[...new Set(badges)].join('/') || '(无)'}` ); check('聚合视图没有"账号取不到"告警', (await page.$('[data-testid="account-errors"]')) === null); // ─── C. 切到单账号:只问这一个账号,且不再画徽标 ────────────────── inboxReqs.length = 0; await page.click('[data-testid="account-switcher"]'); await page.click('[data-testid="account-option-22222222-2222-4222-8222-222222222222"]'); await page.waitForTimeout(2000); await page.screenshot({ path: join(TMP, '30-single.png') }).catch(() => {}); const singleReqs = inboxReqs.filter(r => r.url.includes('status=all')); const singleTokens = new Set(singleReqs.map(r => r.auth.replace(/^Bearer\s+/, ''))); check('★ 切到单账号后只问这一个账号', singleReqs.length === 1 && singleTokens.size === 1, `实际 ${singleReqs.length} 次`); check('★ 用的是乙账号的令牌', [...singleTokens][0] === key2, `实际 ${String([...singleTokens][0]).slice(0, 8)}…`); const badges2 = await page.$$eval('[data-testid="account-badge"]', els => els.length); check('★ 单账号视图不画归属徽标(徽标是聚合专有)', badges2 === 0, `仍有 ${badges2} 个`); // ─── D. 账号页:添加流程真的验证密钥 ────────────────────────────── await page.click('[data-testid="account-switcher"]'); await page.click('[data-testid="account-manage"]'); await page.waitForTimeout(800); await page.click('[data-testid="account-add-toggle"]'); await page.fill('[data-testid="account-gateway"]', GW); // 这一步**故意**发一个 401:把它和真正的页面错误分开统计。 // 判据不是"过滤掉 401",而是"这个 401 确实是这一步造出来的"—— // 直接无视 401 会把真实的鉴权故障也一起放过去。 const beforeBad = consoleErrors.length; await page.fill('[data-testid="account-token"]', 'deadbeef'.repeat(8)); // 64 位但无效 await page.fill('[data-testid="account-name"]', '坏账号'); await page.click('[data-testid="account-add-submit"]'); await page.waitForTimeout(1500); const badMsg = (await page.textContent('[data-testid="account-msg"]').catch(() => '')) || ''; check('★ 无效密钥被当场拒绝(不写进列表)', badMsg.includes('密钥无效'), `提示「${badMsg}」`); const rowsAfterBad = await page.$$eval('[data-testid^="account-row-"]', els => els.length); check('无效密钥没有产生账号行', rowsAfterBad === 2, `实际 ${rowsAfterBad} 行`); await page.screenshot({ path: join(TMP, '40-bad-key.png') }).catch(() => {}); const badWindow = consoleErrors.slice(beforeBad); const intentional401 = badWindow.filter(t => /status of 401/.test(t)); const unexpected = badWindow.filter(t => !/status of 401/.test(t)); check('★ 这一步确实产生了 401(否则上面那条"被拒绝"是假绿)', intentional401.length >= 1, `实际 ${intentional401.length} 条`); check('这一步没有产生 401 以外的页面错误', unexpected.length === 0, unexpected.slice(0, 2).join(' | ')); // 从总数里剔除**已确认由本步产生**的那几条 consoleErrors.splice(beforeBad, intentional401.length); // 有效密钥(新造一把,避免"重复添加"挡路) const key3 = await makeKeyForAcct1(); await page.fill('[data-testid="account-token"]', key3); await page.fill('[data-testid="account-name"]', '丙账号'); await page.click('[data-testid="account-add-submit"]'); await page.waitForTimeout(1500); const okMsg = (await page.textContent('[data-testid="account-msg"]').catch(() => '')) || ''; const rowsAfterGood = await page.$$eval('[data-testid^="account-row-"]', els => els.length); check('★ 有效密钥被接受并写成账号行', rowsAfterGood === 3, `提示「${okMsg}」行数 ${rowsAfterGood}`); await page.screenshot({ path: join(TMP, '50-added.png') }).catch(() => {}); // ─── E. 持久化:落盘内容与界面一致、权限 600 ────────────────────── const mode = (statSync(accountsFile).mode & 0o777).toString(8); const onDisk = JSON.parse(readFileSync(accountsFile, 'utf8')); check('★ 账号落盘到 accounts.json', onDisk.accounts.length === 3, `盘上 ${onDisk.accounts.length} 条`); check('★ 落盘文件权限 600', mode === '600', `实际 ${mode}`); const uiNames = await page.$$eval('[data-testid^="account-row-"]', els => els.map(e => e.textContent)); check( '盘上的账号名与界面一致', onDisk.accounts.every(a => uiNames.some(t => t.includes(a.displayName))), `${onDisk.accounts.map(a => a.displayName).join('/')}` ); check('落盘内容不含明文以外的意外字段(只 6 个字段)', onDisk.accounts.every(a => Object.keys(a).length <= 6)); check('全程没有页面级错误', consoleErrors.length === 0, consoleErrors.slice(0, 2).join(' | ')); } catch (e) { fail++; console.error(`\n ✗ 异常:${e?.stack || e}`); } finally { if (browser) await browser.close().catch(() => {}); gate.kill('SIGKILL'); // 等它真的走完,避免读完就删目录(Electron 退出时会写回配置) await new Promise(r => { const t = setTimeout(r, 3000); gate.on('exit', () => { clearTimeout(t); r(); }); }); writeFileSync(join(TMP, 'accounts-after.json'), readFileSync(accountsFile, 'utf8')); } console.log(`\n === 结果: ${pass} 通过, ${fail} 失败 ===`); process.exitCode = fail === 0 ? 0 : 1;