Files
MailUI4Agents/client/electron/test/manual/multi-account-verify.mjs
JianFeeeee 085e6384a0 test(electron): 多账号真实验收脚本(真起打包产物 + 两个真实账号)
test/manual/multi-account-verify.mjs:预置 accounts.json 后起打包产物,
判据落在**网络层**而不是"列表里有多少行" —— 收件箱按会话分组且默认折叠,
DOM 行数 ≠ 邮件数,用行数当判据只会得到一条随折叠状态变化的假绿/假红。

21 项判据,实测 21/21:
- 聚合 = 每个可用账号各一次收件箱请求、**各带自己的令牌**(抓请求头确认没串号)
- 徽标的存在与否是单/聚合视图的确定性差异(单账号视图不该有徽标)
- 切到单账号只问那一个账号、且带的是它的令牌
- 添加流程:无效密钥当场拒绝(不写进列表)、有效密钥写入并落盘
- 落盘 accounts.json 权限 600、内容与界面一致

★ 修一处**判据自伤**:无效密钥那步故意发 401,最初我把它算成"页面错误"导致
假红;改成"断言这个 401 确实是本步造出来的"(直接过滤 401 会把真实的鉴权故障
一起放过去)。
2026-09-13 06:38:58 +08:00

322 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 多账号验收(真起打包产物 + 两个真实账号)。
*
* 用法:
* 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;