feat(electron): 多账号第一纵切 —— 账号存储/选择器/聚合收件箱
按 docs/MULTI-ACCOUNT-PLAN.md 实现客户端多账号的前半段(SSE 多连接与 写信账号切换留作下一轮)。 - `src/lib/accounts.ts`:纯逻辑(地址规范化、身份判重、默认账号、聚合合并), 16 条测试钉住每条判据(含反向对照)。 - 持久化在主进程:`userData/accounts.json`,**原子写**(临时文件 + rename)+ 0600。不落 localStorage:那份存储渲染层任何脚本都可读,且 file:// 与 http:// 是两套。无 IPC 时(浏览器)退到 localStorage 并在界面**如实写明**。 - 取信:单账号走原路径(逐字节不变);聚合时**每账号各一次请求、各带自己的 令牌**(`fetchWithAuth`,不碰认证单例,避免并发串号)。 - ★ 只合并**同一网关**的账号:跨网关的邮件混进列表后点开会去问当前账号的 服务器(404,或 mail_id 撞上就打开了别人的信)。如实排除 + 列表上方说明。 - ★ 部分失败可见:某账号取不到时给出账号名与原因 —— 静默丢掉它会让聚合列表 少一整份邮件而界面看起来完全正常。 - `API_BASE` 改为 `let`(切换账号要换网关),api 层不得缓存它 (`client.ts` 的 `const BASE` 快照已改成每次读)。 - UI:列表头下拉(≥2 个可用账号才出现「全部邮箱」)+ 账号徽标 + 账号页 「多账号」一段(添加前调 /auth/me 验证,401 当场拒绝,不写进列表)。 - 测试:vitest 230 通过(原 222 + 新 8)、`test/lib/accounts.test.mjs` 16 通过、 typecheck 通过。新增 `test/manual/multi-account-verify.mjs`(真起打包产物 + 两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
This commit is contained in:
132
client/electron/test/components/AccountSwitcher.test.tsx
Normal file
132
client/electron/test/components/AccountSwitcher.test.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 账号选择器的行为测试。
|
||||
*
|
||||
* 判据集中在三件会**静默出错**的事上:
|
||||
* 1. 只有一个账号时不该出现「全部邮箱」(它是噪声,会让人以为漏了谁)
|
||||
* 2. 从"全部邮箱"切到某个账号后**必须重取收件箱** —— 不重取就会继续显示
|
||||
* 聚合结果,看起来像切换没生效
|
||||
* 3. 没有账号时不该渲染这个控件(那时界面该由登录页负责)
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import AccountSwitcher from '../../src/components/AccountSwitcher';
|
||||
import { useAccountStore } from '../../src/stores/accountStore';
|
||||
import { useMailStore } from '../../src/stores/mailStore';
|
||||
import { useUIStore } from '../../src/stores/uiStore';
|
||||
import * as api from '../../src/api/client';
|
||||
|
||||
const acct = (id: string, name: string) => ({
|
||||
id,
|
||||
displayName: name,
|
||||
gateway: 'http://192.168.2.60:8180',
|
||||
token: `tok-${id}`,
|
||||
username: name
|
||||
});
|
||||
|
||||
function seed(accounts: ReturnType<typeof acct>[], activeId: string) {
|
||||
useAccountStore.setState({ accounts, activeId, loaded: true } as never);
|
||||
}
|
||||
|
||||
describe('AccountSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(api, 'getInbox').mockResolvedValue({ mails: [], total: 0 } as never);
|
||||
useAccountStore.setState({ accounts: [], activeId: 'all', loaded: true } as never);
|
||||
useMailStore.setState({ inbox: [], accountErrors: [] });
|
||||
useUIStore.setState({ viewMode: 'inbox' });
|
||||
});
|
||||
|
||||
it('没有账号时不渲染(那时该由登录页负责)', () => {
|
||||
const { container } = render(<AccountSwitcher />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('★ 只有一个账号时退化成静态标题:不出现「全部邮箱」', async () => {
|
||||
seed([acct('a1', '工作邮箱')], 'a1');
|
||||
render(<AccountSwitcher />);
|
||||
const btn = screen.getByTestId('account-switcher');
|
||||
expect(btn).toHaveTextContent('工作邮箱');
|
||||
|
||||
await userEvent.click(btn);
|
||||
// 点不开:聚合没有意义(一个账号的"全部"就是它自己)
|
||||
expect(screen.queryByTestId('account-menu')).toBeNull();
|
||||
expect(screen.queryByTestId('account-option-all')).toBeNull();
|
||||
});
|
||||
|
||||
it('两个账号时可以展开,菜单里有「全部邮箱」与各账号', async () => {
|
||||
seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'a1');
|
||||
render(<AccountSwitcher />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('account-switcher'));
|
||||
expect(screen.getByTestId('account-menu')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('account-option-all')).toHaveTextContent('全部邮箱');
|
||||
expect(screen.getByTestId('account-option-a1')).toHaveTextContent('工作邮箱');
|
||||
expect(screen.getByTestId('account-option-a2')).toHaveTextContent('私人邮箱');
|
||||
});
|
||||
|
||||
it('★ 切到某个账号后必须重取收件箱(否则会继续显示聚合结果)', async () => {
|
||||
seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'all');
|
||||
render(<AccountSwitcher />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('account-switcher'));
|
||||
await userEvent.click(screen.getByTestId('account-option-a2'));
|
||||
|
||||
await waitFor(() => expect(useAccountStore.getState().activeId).toBe('a2'));
|
||||
expect(api.getInbox).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('★ 切到「全部邮箱」也要重取(聚合要并发问多个账号)', async () => {
|
||||
seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'a1');
|
||||
render(<AccountSwitcher />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('account-switcher'));
|
||||
await userEvent.click(screen.getByTestId('account-option-all'));
|
||||
|
||||
await waitFor(() => expect(useAccountStore.getState().activeId).toBe('all'));
|
||||
// 两个可用账号 → 走聚合路径(getInboxWithAuth 每个账号一次)
|
||||
const withAuth = vi.spyOn(api, 'getInboxWithAuth').mockResolvedValue({ mails: [], total: 0 } as never);
|
||||
await useMailStore.getState().fetchInbox('all');
|
||||
expect(withAuth).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('聚合模式下**只有一个可用账号**时不并发两个请求(第二条是多余失败面)', async () => {
|
||||
useAccountStore.setState({
|
||||
accounts: [{ ...acct('a1', '工作邮箱') }, { ...acct('a2', '坏账号'), token: '' }],
|
||||
activeId: 'all',
|
||||
loaded: true
|
||||
} as never);
|
||||
const withAuth = vi.spyOn(api, 'getInboxWithAuth').mockResolvedValue({ mails: [], total: 0 } as never);
|
||||
await useMailStore.getState().fetchInbox('all');
|
||||
expect(withAuth).not.toHaveBeenCalled();
|
||||
expect(api.getInbox).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('★ 某个账号取失败必须在 store 里说出来,而不是静默少一份', async () => {
|
||||
useAccountStore.setState({
|
||||
accounts: [acct('a1', '工作邮箱'), acct('a2', '私人邮箱')],
|
||||
activeId: 'all',
|
||||
loaded: true
|
||||
} as never);
|
||||
vi.spyOn(api, 'getInboxWithAuth')
|
||||
.mockResolvedValueOnce({ mails: [{ mail_id: 'm1' }], total: 1 } as never)
|
||||
.mockRejectedValueOnce(new Error('HTTP 401:密钥无效'));
|
||||
|
||||
await useMailStore.getState().fetchInbox('all');
|
||||
|
||||
expect(useMailStore.getState().inbox.map((m: any) => m.mail_id)).toEqual(['m1']);
|
||||
expect(useMailStore.getState().accountErrors).toEqual([
|
||||
{ account: '私人邮箱', error: 'HTTP 401:密钥无效' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('「管理账号…」跳到账号页', async () => {
|
||||
seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'a1');
|
||||
render(<AccountSwitcher />);
|
||||
await userEvent.click(screen.getByTestId('account-switcher'));
|
||||
await userEvent.click(screen.getByTestId('account-manage'));
|
||||
expect(useUIStore.getState().viewMode).toBe('account');
|
||||
});
|
||||
});
|
||||
@ -1,7 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import LoginPage from '../../src/components/LoginPage';
|
||||
import * as api from '../../src/api/client';
|
||||
|
||||
186
client/electron/test/lib/accounts.test.mjs
Normal file
186
client/electron/test/lib/accounts.test.mjs
Normal file
@ -0,0 +1,186 @@
|
||||
/**
|
||||
* `src/lib/accounts.ts` 的测试。
|
||||
*
|
||||
* 这一层的判据是「多账号怎么合并、谁是默认、什么算同一个身份」——
|
||||
* 它们错了都不会报错,只会表现为「同一封信出现两次」「切了账号还是老数据」
|
||||
* 「重复添加了三个同样的账号」。所以每条都配了反向对照。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
AGGREGATE_ID,
|
||||
normalizeGateway,
|
||||
deriveDisplayName,
|
||||
accountAuth,
|
||||
isUsableAccount,
|
||||
upsertAccount,
|
||||
removeAccount,
|
||||
sameIdentity,
|
||||
pickDefaultAccountId,
|
||||
touchAccount,
|
||||
mergeInboxes
|
||||
} from '../../src/lib/accounts.ts';
|
||||
|
||||
const acct = (over = {}) => ({
|
||||
id: 'a1',
|
||||
displayName: '工作邮箱',
|
||||
gateway: 'http://192.168.2.60:8180',
|
||||
token: 'k1',
|
||||
...over
|
||||
});
|
||||
|
||||
// ─── 地址规范化 ─────────────────────────────────────────────────────────
|
||||
|
||||
test('★ 网关地址的各种写法都收敛成同一个(否则会重复添加同一个账号)', () => {
|
||||
const want = 'http://192.168.2.60:8180';
|
||||
for (const raw of [
|
||||
'http://192.168.2.60:8180',
|
||||
'http://192.168.2.60:8180/',
|
||||
'http://192.168.2.60:8180/api/v1',
|
||||
'http://192.168.2.60:8180/api/v1/',
|
||||
' http://192.168.2.60:8180 ',
|
||||
'192.168.2.60:8180', // 用户常只写 ip:port
|
||||
'192.168.2.60:8180/'
|
||||
]) {
|
||||
assert.equal(normalizeGateway(raw), want, `输入 ${JSON.stringify(raw)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('空串 / 缺协议以外的东西不炸', () => {
|
||||
assert.equal(normalizeGateway(''), '');
|
||||
assert.equal(normalizeGateway(' '), '');
|
||||
assert.equal(normalizeGateway(undefined), '');
|
||||
// https 不该被改写成 http
|
||||
assert.equal(normalizeGateway('https://mail.example.com'), 'https://mail.example.com');
|
||||
});
|
||||
|
||||
test('★ accountAuth 拼出的 base 恰好是 /api/v1(不能出现双份)', () => {
|
||||
assert.deepEqual(accountAuth(acct()), { base: 'http://192.168.2.60:8180/api/v1', token: 'k1' });
|
||||
// 粘贴了带 /api/v1 的地址也不该拼成 /api/v1/api/v1
|
||||
assert.equal(
|
||||
accountAuth(acct({ gateway: 'http://h:8180/api/v1' })).base,
|
||||
'http://h:8180/api/v1'
|
||||
);
|
||||
});
|
||||
|
||||
test('显示名可派生:有用户名带用户名,没有就用主机名', () => {
|
||||
assert.equal(deriveDisplayName('http://192.168.2.60:8180', 'gui-lab'), 'gui-lab@192.168.2.60:8180');
|
||||
assert.equal(deriveDisplayName('http://192.168.2.60:8180'), '192.168.2.60:8180');
|
||||
assert.equal(deriveDisplayName(''), '未命名账号');
|
||||
});
|
||||
|
||||
test('可用性判据:有地址且有令牌才算可用', () => {
|
||||
assert.equal(isUsableAccount(acct()), true);
|
||||
assert.equal(isUsableAccount(acct({ token: ' ' })), false);
|
||||
assert.equal(isUsableAccount(acct({ gateway: '' })), false);
|
||||
assert.equal(isUsableAccount(null), false);
|
||||
// 反向对照:宽松是刻意的 —— 自建网关的相对地址也不该在这里被拒
|
||||
assert.equal(isUsableAccount(acct({ gateway: 'localhost:8180' })), true);
|
||||
});
|
||||
|
||||
// ─── 增删与身份判重 ─────────────────────────────────────────────────────
|
||||
|
||||
test('★ 同一个身份不能重复添加(id 不同但网关+令牌相同)', () => {
|
||||
const a = acct({ id: 'a1' });
|
||||
const b = acct({ id: 'a2' }); // 新建时 uuid 必然不同,所以不能靠 id 判重
|
||||
assert.equal(sameIdentity(a, b), true);
|
||||
// 网关写法不同但其实是同一台机器 → 也算同一个身份
|
||||
assert.equal(sameIdentity(a, acct({ gateway: '192.168.2.60:8180/' })), true);
|
||||
// 反向对照:换了令牌或换了网关就不是同一个身份
|
||||
assert.equal(sameIdentity(a, acct({ token: 'k2' })), false);
|
||||
assert.equal(sameIdentity(a, acct({ gateway: 'http://other:8180' })), false);
|
||||
});
|
||||
|
||||
test('upsert 同 id 覆盖、不产生两条', () => {
|
||||
const list = [acct({ id: 'a1', displayName: '旧名' })];
|
||||
const out = upsertAccount(list, acct({ id: 'a1', displayName: '新名' }));
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].displayName, '新名');
|
||||
const out2 = upsertAccount(out, acct({ id: 'b1', displayName: '另一个' }));
|
||||
assert.equal(out2.length, 2);
|
||||
});
|
||||
|
||||
test('删除幂等:删不存在的 id 返回原样', () => {
|
||||
const list = [acct({ id: 'a1' }), acct({ id: 'a2' })];
|
||||
assert.equal(removeAccount(list, 'a1').length, 1);
|
||||
assert.equal(removeAccount(list, 'nope').length, 2);
|
||||
});
|
||||
|
||||
// ─── 默认账号 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('★ 默认账号 = 最近用过的那个;都没用过则取第一个', () => {
|
||||
const list = [
|
||||
acct({ id: 'a1' }),
|
||||
acct({ id: 'a2', lastUsed: '2026-09-07T12:00:00Z' }),
|
||||
acct({ id: 'a3', lastUsed: '2026-09-08T12:00:00Z' })
|
||||
];
|
||||
assert.equal(pickDefaultAccountId(list), 'a3');
|
||||
// 反向对照:一个都没有 lastUsed 时取列表第一个(顺序稳定由调用方保证)
|
||||
assert.equal(pickDefaultAccountId([acct({ id: 'a1' }), acct({ id: 'a2' })]), 'a1');
|
||||
// 空列表返回空串,不是抛错
|
||||
assert.equal(pickDefaultAccountId([]), '');
|
||||
});
|
||||
|
||||
test('touch 只改那一个账号的时间,不动顺序与其它字段', () => {
|
||||
const list = [acct({ id: 'a1' }), acct({ id: 'a2' })];
|
||||
const out = touchAccount(list, 'a2', '2026-09-12T00:00:00Z');
|
||||
assert.equal(out[1].lastUsed, '2026-09-09T00:00:00Z'.replace('2026-09-09', '2026-09-12'));
|
||||
assert.equal(out[0].lastUsed, undefined);
|
||||
assert.deepEqual(out.map(a => a.id), ['a1', 'a2']);
|
||||
});
|
||||
|
||||
// ─── 聚合合并 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('★ 合并按时间倒序(不是把几个收件箱拼起来)', () => {
|
||||
const merged = mergeInboxes([
|
||||
{ account: { id: 'a1', displayName: '甲' }, mails: [{ mail_id: 'm1', created_at: '2026-09-01T00:00:00Z' }] },
|
||||
{ account: { id: 'a2', displayName: '乙' }, mails: [{ mail_id: 'm2', created_at: '2026-09-05T00:00:00Z' }] }
|
||||
]);
|
||||
assert.deepEqual(merged.map(m => m.mail_id), ['m2', 'm1']);
|
||||
});
|
||||
|
||||
test('★ 同一 mail_id 只留一条,且归属是第一条胜出', () => {
|
||||
const merged = mergeInboxes([
|
||||
{ account: { id: 'a1', displayName: '甲' }, mails: [{ mail_id: 'same', created_at: '2026-09-01T00:00:00Z' }] },
|
||||
{ account: { id: 'a2', displayName: '乙' }, mails: [{ mail_id: 'same', created_at: '2026-09-02T00:00:00Z' }] }
|
||||
]);
|
||||
assert.equal(merged.length, 1, '同一封信出现两次 ⇒ 删除/已读只在一侧生效,看起来"点过了还在"');
|
||||
assert.equal(merged[0].account_id, 'a1');
|
||||
assert.equal(merged[0].account_name, '甲');
|
||||
});
|
||||
|
||||
test('★ 缺 created_at 的排在最后(不能因为缺字段就排到最前)', () => {
|
||||
const merged = mergeInboxes([
|
||||
{ account: { id: 'a1', displayName: '甲' }, mails: [{ mail_id: 'no-time' }] },
|
||||
{ account: { id: 'a2', displayName: '乙' }, mails: [{ mail_id: 'has-time', created_at: '2020-01-01T00:00:00Z' }] }
|
||||
]);
|
||||
assert.deepEqual(merged.map(m => m.mail_id), ['has-time', 'no-time']);
|
||||
});
|
||||
|
||||
test('每条都带归属(界面才有徽标可画)', () => {
|
||||
const merged = mergeInboxes([
|
||||
{ account: { id: 'a1', displayName: '甲' }, mails: [{ mail_id: 'm1' }] },
|
||||
{ account: { id: 'a2', displayName: '乙' }, mails: [{ mail_id: 'm2' }] }
|
||||
]);
|
||||
assert.deepEqual(
|
||||
merged.map(m => m.account_name).sort(),
|
||||
['乙', '甲']
|
||||
);
|
||||
});
|
||||
|
||||
test('空账号 / 空列表都不炸', () => {
|
||||
assert.deepEqual(mergeInboxes([]), []);
|
||||
assert.deepEqual(mergeInboxes([{ account: { id: 'a1', displayName: '甲' }, mails: [] }]), []);
|
||||
// 没有 mail_id 的条目不能把其它条目挤掉(它们各自都留下来)
|
||||
const merged = mergeInboxes([
|
||||
{ account: { id: 'a1', displayName: '甲' }, mails: [{ created_at: '2026-01-01T00:00:00Z' }, {}] }
|
||||
]);
|
||||
assert.equal(merged.length, 2);
|
||||
});
|
||||
|
||||
test('聚合 id 与真实账号 id 不会冲突(真实 id 是 uuid)', () => {
|
||||
assert.equal(AGGREGATE_ID, 'all');
|
||||
assert.equal(/^[0-9a-f-]{36}$/.test(AGGREGATE_ID), false, '聚合 id 不能长得像 uuid');
|
||||
});
|
||||
309
client/electron/test/manual/multi-account-verify.mjs
Normal file
309
client/electron/test/manual/multi-account-verify.mjs
Normal file
@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 多账号验收(真起打包产物 + 两个真实账号)。
|
||||
*
|
||||
* 用法:
|
||||
* 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);
|
||||
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 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;
|
||||
Reference in New Issue
Block a user