chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
264
client/electron/test/components/AddressInput.test.tsx
Normal file
264
client/electron/test/components/AddressInput.test.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import AddressInput from '../../src/components/AddressInput';
|
||||
import * as api from '../../src/api/client';
|
||||
import type { SessionCandidate } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 三段式地址补全。
|
||||
*
|
||||
* 每条断言都对应一个真实存在过或很容易犯的错:
|
||||
* - 问错层(写了 @ 还在问 name 的候选)
|
||||
* - 过滤后 suggestions 与 candidates 错位,标题挂到别的别名上
|
||||
* - 选中 name 后没自动补 `@`,人得自己敲
|
||||
* - session 段选完还开着下拉,挡住下面的输入框
|
||||
*/
|
||||
|
||||
/** 造一个 suggestAddress 的响应。candidates 与 suggestions 必须同序同长。 */
|
||||
function res(
|
||||
kind: 'name' | 'path' | 'session',
|
||||
suggestions: string[],
|
||||
candidates?: Partial<SessionCandidate>[]
|
||||
) {
|
||||
return {
|
||||
kind,
|
||||
suggestions,
|
||||
candidates: candidates?.map((c, i) => ({
|
||||
alias: suggestions[i],
|
||||
title: '',
|
||||
source: 'mail' as const,
|
||||
unread: 0,
|
||||
...c
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/** 渲染一个受控的 AddressInput,返回 input 与「当前值」读取器。 */
|
||||
function setup(initial = '') {
|
||||
let current = initial;
|
||||
const onChange = vi.fn((v: string) => {
|
||||
current = v;
|
||||
rerender();
|
||||
});
|
||||
const { rerender: rr } = render(
|
||||
React.createElement(AddressInput, { value: current, onChange })
|
||||
);
|
||||
function rerender() {
|
||||
rr(React.createElement(AddressInput, { value: current, onChange }));
|
||||
}
|
||||
return {
|
||||
onChange,
|
||||
value: () => current,
|
||||
input: () => screen.getByRole('textbox') as HTMLInputElement
|
||||
};
|
||||
}
|
||||
|
||||
describe('AddressInput 三段式补全', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('还没写 @ 时问 name 层', async () => {
|
||||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'ds');
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||
// 问 name 层时不带任何参数
|
||||
expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual([]);
|
||||
});
|
||||
|
||||
it('写了 @ 没写 . 时带 name 去问 path 层', async () => {
|
||||
const spy = vi
|
||||
.spyOn(api, 'suggestAddress')
|
||||
.mockResolvedValue(res('path', ['/home/program/agentmail']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'dsh@/home');
|
||||
|
||||
// 问错层的后果:候选里全是 Agent 名,人以为这个工作区下没有会话
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh'));
|
||||
});
|
||||
|
||||
it('写了 . 时带 name 与 path 去问 session 层', async () => {
|
||||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'dsh@/home/x.ref');
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/x'));
|
||||
});
|
||||
|
||||
it('path 里的 . 不被当作 session 分隔符(按最后一个 . 切)', async () => {
|
||||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['a']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
// path 自身含 .:a.b/proj
|
||||
await userEvent.type(input(), 'dsh@/home/a.b/proj.ref');
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/a.b/proj'));
|
||||
});
|
||||
|
||||
it('选中 name 候选后自动补 @', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('dsh')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('dsh'));
|
||||
|
||||
// 不补 @ 的话人得自己敲,而这是三段里唯一没有歧义的分隔符
|
||||
expect(value()).toBe('dsh@');
|
||||
});
|
||||
|
||||
it('选中 path 候选后自动补 .', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('path', ['/home/program/agentmail']));
|
||||
const { input, value } = setup('dsh@');
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('/home/program/agentmail')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('/home/program/agentmail'));
|
||||
|
||||
expect(value()).toBe('dsh@/home/program/agentmail.');
|
||||
});
|
||||
|
||||
it('选中 session 候选后拼出完整地址并关掉下拉', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor', 'new']));
|
||||
const { input, value } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('refactor')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('refactor'));
|
||||
|
||||
expect(value()).toBe('dsh@/home/x.refactor');
|
||||
// session 是最后一段,选完还开着下拉会挡住下面的输入框
|
||||
await waitFor(() => expect(screen.queryByText('会话别名(new 为新建)')).toBeNull());
|
||||
});
|
||||
|
||||
it('标题参与过滤,且过滤后标题不错位到别的别名上', async () => {
|
||||
// 三条候选,只有第二条的标题含「缓存」
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||||
res(
|
||||
'session',
|
||||
['witty-planet', 'brisk-harbor', 'calm-river'],
|
||||
[{ title: '重构导入路径' }, { title: '缓存选型讨论' }, { title: '修 CI' }]
|
||||
)
|
||||
);
|
||||
const { input } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), '缓存');
|
||||
|
||||
await waitFor(() => {
|
||||
// 命中的是 brisk-harbor,标题必须还是它自己的
|
||||
expect(screen.getByText('brisk-harbor')).toBeInTheDocument();
|
||||
expect(screen.getByText('缓存选型讨论')).toBeInTheDocument();
|
||||
});
|
||||
// 分别过滤两个数组会让 witty-planet 的标题挂到 brisk-harbor 上
|
||||
expect(screen.queryByText('重构导入路径')).toBeNull();
|
||||
expect(screen.queryByText('witty-planet')).toBeNull();
|
||||
});
|
||||
|
||||
it('platform 来源的候选标出「平台」,new 标出「新建会话」', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||||
res(
|
||||
'session',
|
||||
['ses-mirror', 'new'],
|
||||
[
|
||||
{ title: '平台侧在跑的会话', source: 'platform' },
|
||||
{ source: 'new' }
|
||||
]
|
||||
)
|
||||
);
|
||||
const { input } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
|
||||
await waitFor(() => {
|
||||
// 不标的话人不知道这一封是「接入」一条已在跑的会话
|
||||
expect(screen.getByText('平台')).toBeInTheDocument();
|
||||
expect(screen.getByText('新建会话')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('未读数显示在候选上', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||||
res('session', ['busy-session'], [{ title: '有新消息', unread: 3 }])
|
||||
);
|
||||
const { input } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
|
||||
await waitFor(() => expect(screen.getByText('3')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('键盘上下键移动选中项,Enter 采用', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}{Enter}');
|
||||
|
||||
// 初始 active=0(dsh),下移一格到 opencode
|
||||
expect(value()).toBe('opencode@');
|
||||
});
|
||||
|
||||
it('Escape 关掉下拉但不清空已输入的内容', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh']));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'ds');
|
||||
await waitFor(() => expect(screen.getByText('Agent 名')).toBeInTheDocument());
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
|
||||
expect(value()).toBe('ds');
|
||||
});
|
||||
|
||||
it('接口失败时静默清空候选,不炸也不弹错', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockRejectedValue(new Error('network down'));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'ds');
|
||||
|
||||
// 补全只是便利功能,失败不该阻止人手动输入完整地址
|
||||
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
|
||||
expect(value()).toBe('ds');
|
||||
});
|
||||
|
||||
it('allowMultiple 时补全只作用于最后一段,前面的地址保留', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['opencode']));
|
||||
|
||||
let current = 'dsh@/home/x.a, ';
|
||||
const onChange = vi.fn((v: string) => {
|
||||
current = v;
|
||||
});
|
||||
render(
|
||||
React.createElement(AddressInput, {
|
||||
value: current,
|
||||
onChange,
|
||||
allowMultiple: true
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('textbox'));
|
||||
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('opencode'));
|
||||
|
||||
// 抄送场景:前面已填好的地址不能被补全覆盖
|
||||
expect(current).toBe('dsh@/home/x.a, opencode@');
|
||||
});
|
||||
});
|
||||
80
client/electron/test/components/BudgetChip.test.tsx
Normal file
80
client/electron/test/components/BudgetChip.test.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { BudgetChip } from '../../src/components/WorkCard';
|
||||
|
||||
/**
|
||||
* 往返预算徽标。
|
||||
*
|
||||
* 它是「这个任务还剩几个来回」的唯一视觉提示,两件事必须对:
|
||||
* - 显示的是**剩余**而不是已用(人做决定看的是「还能问几次」)
|
||||
* - 0 = 不限时**不显示**,而不是显示「0/0」
|
||||
*/
|
||||
describe('BudgetChip 预算渲染', () => {
|
||||
it('显示剩余数而不是已用数', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 3 }));
|
||||
|
||||
// 20 个上限、用了 3 个 → 剩 17。显示 3/20 会让人以为快用完了
|
||||
expect(screen.getByText('17/20')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('max 为 0(不限)时完全不渲染', () => {
|
||||
const { container } = render(React.createElement(BudgetChip, { max: 0, used: 0 }));
|
||||
|
||||
// 「0/0」或「不限」对每张卡片都成立,等于纯噪声
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('max 为负数时也不渲染(脏数据兜底)', () => {
|
||||
const { container } = render(React.createElement(BudgetChip, { max: -1, used: 0 }));
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('用超时剩余按 0 显示,不出现负数', () => {
|
||||
render(React.createElement(BudgetChip, { max: 5, used: 8 }));
|
||||
|
||||
// 「-3/5」看起来像 bug,而实际情形(免配额转发不计数、人手动下调过上限)是合法的
|
||||
expect(screen.getByText('0/5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('剩余为 0 时转红并标注「已用尽」', () => {
|
||||
render(React.createElement(BudgetChip, { max: 5, used: 5 }));
|
||||
|
||||
const chip = screen.getByText('0/5').closest('span')!;
|
||||
expect(chip.className).toContain('bg-red-100');
|
||||
expect(chip.getAttribute('title')).toContain('已用尽');
|
||||
});
|
||||
|
||||
it('剩余 1 个时转橙 —— 那是需要人介入的时刻', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 19 }));
|
||||
|
||||
const chip = screen.getByText('1/20').closest('span')!;
|
||||
// 要么加预算,要么让它收尾;这一步不提示的话下一封信就被挡住了
|
||||
expect(chip.className).toContain('bg-orange-100');
|
||||
});
|
||||
|
||||
it('余量充足时用中性灰,不抢注意力', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 2 }));
|
||||
|
||||
const chip = screen.getByText('18/20').closest('span')!;
|
||||
expect(chip.className).toContain('bg-gray-100');
|
||||
expect(chip.className).not.toContain('red');
|
||||
expect(chip.className).not.toContain('orange');
|
||||
});
|
||||
|
||||
it('title 里带已用/上限,供悬停查看细节', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 7 }));
|
||||
|
||||
const chip = screen.getByText('13/20').closest('span')!;
|
||||
// 徽标上只有剩余,具体用了几个放在 title 里 —— 徽标要窄
|
||||
expect(chip.getAttribute('title')).toContain('已用 7/20');
|
||||
});
|
||||
|
||||
it('剩余 2 个时还不转橙(阈值是 <= 1)', () => {
|
||||
render(React.createElement(BudgetChip, { max: 10, used: 8 }));
|
||||
|
||||
const chip = screen.getByText('2/10').closest('span')!;
|
||||
expect(chip.className).toContain('bg-gray-100');
|
||||
});
|
||||
});
|
||||
203
client/electron/test/components/PermissionPanel.test.tsx
Normal file
203
client/electron/test/components/PermissionPanel.test.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { PermissionPanel } from '../../src/components/MailView';
|
||||
import * as api from '../../src/api/client';
|
||||
import { useMailStore } from '../../src/stores/mailStore';
|
||||
import { useSessionStore } from '../../src/stores/sessionStore';
|
||||
import type { Mail } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 权限决策面板。
|
||||
*
|
||||
* 这是全站唯一一处「人的一次点击直接放行 Agent 的危险操作」,
|
||||
* 因此断言集中在两件事:
|
||||
* - 点下去到底把什么发给了服务端(选项原文,不是归一化后的 allow/deny)
|
||||
* - 已决策的请求不能再点第二次
|
||||
*/
|
||||
|
||||
function permMail(over: Partial<Mail> = {}): Mail {
|
||||
return {
|
||||
mail_id: 'm-1',
|
||||
session_id: 's-1',
|
||||
parent_mail_id: null,
|
||||
from_name: 'dsh',
|
||||
from_workspace: '/home/program/agentmail',
|
||||
to_name: 'admin',
|
||||
to_workspace: '',
|
||||
cc_list: [],
|
||||
subject: '请求批准:删除 build/',
|
||||
body: '将执行 rm -rf build/',
|
||||
mail_type: 'permission_request',
|
||||
permission_options: undefined,
|
||||
permission_result: '',
|
||||
status: 'unread',
|
||||
created_at: '2026-09-03T00:00:00Z',
|
||||
hop_limit: 5,
|
||||
...over
|
||||
} as Mail;
|
||||
}
|
||||
|
||||
describe('PermissionPanel 决策', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// fetchInbox / selectSession 会打网络,替换成空实现
|
||||
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
|
||||
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
|
||||
});
|
||||
|
||||
it('没有 permission_options 时给默认的同意/拒绝两个选项', () => {
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /拒绝/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有 permission_options 时用它,且顺序保持', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['只这一次', '总是允许', '拒绝'] })
|
||||
})
|
||||
);
|
||||
|
||||
const btns = screen.getAllByRole('button').map(b => b.textContent?.trim());
|
||||
// 顺序是 Agent 给的语义顺序,重排会让「拒绝」跑到人的手指默认位置上
|
||||
expect(btns).toEqual(['只这一次', '总是允许', '拒绝']);
|
||||
});
|
||||
|
||||
it('点选项时把【选项原文】发给服务端', async () => {
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['只这一次', '拒绝'] })
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /只这一次/ }));
|
||||
|
||||
// 关键:不能归一化成 allow/deny —— 「只这一次」与「总是允许」的区别
|
||||
// 只有 Agent 侧的权限机制懂,服务端与前端都不该替它翻译
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith('m-1', '只这一次', undefined)
|
||||
);
|
||||
});
|
||||
|
||||
it('填了备注时一起发出去', async () => {
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('备注(可选)'), '只删 build,别动 dist');
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith('m-1', '同意', '只删 build,别动 dist')
|
||||
);
|
||||
});
|
||||
|
||||
it('备注为空时传 undefined 而不是空字符串', async () => {
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 空串会在决策邮件里留一行空的「备注:」
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('m-1', '同意', undefined));
|
||||
});
|
||||
|
||||
it('决策后变成「已处理」,不再显示按钮', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
// 还能点第二次的话人会以为第一次没生效,而服务端那边早已决策
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('已经有 permission_result 的邮件直接显示结论', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_result: '拒绝' })
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText('拒绝')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('提交中禁用所有按钮,避免重复决策', async () => {
|
||||
let release: (v: any) => void = () => {};
|
||||
vi.spyOn(api, 'decidePermission').mockReturnValue(
|
||||
new Promise(res => {
|
||||
release = res;
|
||||
}) as any
|
||||
);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 一次危险操作被批准两次,Agent 那边可能真的执行两遍
|
||||
await waitFor(() => {
|
||||
for (const b of screen.getAllByRole('button')) {
|
||||
expect(b).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
// 收尾:让悬挂的 Promise 落定并等状态更新走完,
|
||||
// 否则组件在测试结束后才 setState,React 会报 act 警告
|
||||
await act(async () => {
|
||||
release({ status: 'decided' });
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('提交失败时恢复可点,不假装已决策', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockRejectedValue(new Error('500'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 失败后显示「已处理」是最糟的结果:人以为批过了,Agent 还在等
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /同意/ })).toBeEnabled());
|
||||
expect(screen.queryByText('已处理:')).toBeNull();
|
||||
});
|
||||
|
||||
it('决策成功后刷新收件箱并选中该会话', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
const fetchInbox = vi.fn(async () => {});
|
||||
const selectSession = vi.fn(async () => {});
|
||||
useMailStore.setState({ fetchInbox } as any);
|
||||
useSessionStore.setState({ selectSession } as any);
|
||||
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 不刷新的话列表里那封还是「未读的权限请求」,人会以为没生效
|
||||
await waitFor(() => {
|
||||
expect(fetchInbox).toHaveBeenCalledWith('all');
|
||||
expect(selectSession).toHaveBeenCalledWith('s-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('同意类选项用绿色,其余用红色', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['允许', 'approve', '拒绝', '算了'] })
|
||||
})
|
||||
);
|
||||
|
||||
const cls = (name: string) =>
|
||||
screen.getByRole('button', { name: new RegExp(name) }).className;
|
||||
|
||||
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
|
||||
expect(cls('允许')).toContain('bg-green-700');
|
||||
expect(cls('approve')).toContain('bg-green-700');
|
||||
expect(cls('拒绝')).toContain('text-red-700');
|
||||
// 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼
|
||||
expect(cls('算了')).toContain('text-red-700');
|
||||
});
|
||||
});
|
||||
281
client/electron/test/components/calendar.test.tsx
Normal file
281
client/electron/test/components/calendar.test.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
startOfDay, endOfDay, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
isSameDay, addDays, addMonths, monthGrid, weekDays, remindAt,
|
||||
bucketByDay, dayKey, renderReminder, toLocalInput, fromLocalInput,
|
||||
describeRecurrence, describeRemindBefore
|
||||
} from '../../src/lib/calendar';
|
||||
import type { CalendarEvent } from '../../src/types';
|
||||
|
||||
let seq = 0;
|
||||
function ev(over: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
seq += 1;
|
||||
return {
|
||||
event_id: `e${String(seq).padStart(3, '0')}`,
|
||||
title: `事件 ${seq}`,
|
||||
description: '',
|
||||
reminder_text: '',
|
||||
agent_name: 'pi',
|
||||
to_address: '',
|
||||
recipients: [],
|
||||
delivery_mode: 'separate',
|
||||
event_time: '2026-09-03T14:30:00+08:00',
|
||||
remind_before: 0,
|
||||
recurrence: 'none',
|
||||
status: 'active',
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
updated_at: '2026-09-01T00:00:00Z',
|
||||
created_by: 'jianf',
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
describe('日期边界', () => {
|
||||
it('startOfDay / endOfDay 用本地时区', () => {
|
||||
const d = new Date(2026, 8, 3, 14, 30, 45, 123);
|
||||
expect(startOfDay(d).getHours()).toBe(0);
|
||||
expect(startOfDay(d).getMilliseconds()).toBe(0);
|
||||
expect(endOfDay(d).getHours()).toBe(23);
|
||||
expect(endOfDay(d).getMilliseconds()).toBe(999);
|
||||
// 不修改入参
|
||||
expect(d.getHours()).toBe(14);
|
||||
});
|
||||
|
||||
it('startOfMonth / endOfMonth', () => {
|
||||
const d = new Date(2026, 8, 15);
|
||||
expect(startOfMonth(d).getDate()).toBe(1);
|
||||
expect(endOfMonth(d).getDate()).toBe(30); // 9 月 30 天
|
||||
expect(endOfMonth(new Date(2026, 1, 5)).getDate()).toBe(28); // 2026 年 2 月
|
||||
});
|
||||
|
||||
it('周从周一开始,周日归到上一周末尾', () => {
|
||||
// 2026-09-06 是周日
|
||||
const sunday = new Date(2026, 8, 6);
|
||||
expect(sunday.getDay()).toBe(0);
|
||||
// 它所在周的周一应是 08-31,不是 09-07
|
||||
expect(dayKey(startOfWeek(sunday))).toBe('2026-08-31');
|
||||
expect(dayKey(endOfWeek(sunday))).toBe('2026-09-06');
|
||||
});
|
||||
|
||||
it('周一自己就是周首', () => {
|
||||
const monday = new Date(2026, 8, 7);
|
||||
expect(monday.getDay()).toBe(1);
|
||||
expect(dayKey(startOfWeek(monday))).toBe('2026-09-07');
|
||||
});
|
||||
});
|
||||
|
||||
describe('日期运算', () => {
|
||||
it('addDays 不修改入参', () => {
|
||||
const d = new Date(2026, 8, 3);
|
||||
const r = addDays(d, 5);
|
||||
expect(dayKey(r)).toBe('2026-09-08');
|
||||
expect(dayKey(d)).toBe('2026-09-03');
|
||||
});
|
||||
|
||||
it('addDays 跨月', () => {
|
||||
expect(dayKey(addDays(new Date(2026, 8, 29), 5))).toBe('2026-10-04');
|
||||
});
|
||||
|
||||
it('addMonths 从月末加一个月不会溢出', () => {
|
||||
// setMonth 在 1 月 31 日上加一个月会给 3 月 2/3 日
|
||||
const jan31 = new Date(2026, 0, 31);
|
||||
expect(dayKey(addMonths(jan31, 1))).toBe('2026-02-01');
|
||||
});
|
||||
|
||||
it('isSameDay 只比年月日', () => {
|
||||
expect(isSameDay(new Date(2026, 8, 3, 0, 0), new Date(2026, 8, 3, 23, 59))).toBe(true);
|
||||
expect(isSameDay(new Date(2026, 8, 3), new Date(2026, 8, 4))).toBe(false);
|
||||
expect(isSameDay(new Date(2026, 8, 3), new Date(2025, 8, 3))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthGrid', () => {
|
||||
it('固定 42 格(6 行 × 7 列)', () => {
|
||||
// 行数变化会让网格高度跳动,翻月时页面内容上下弹
|
||||
expect(monthGrid(new Date(2026, 8, 15))).toHaveLength(42);
|
||||
expect(monthGrid(new Date(2026, 1, 15))).toHaveLength(42);
|
||||
});
|
||||
|
||||
it('首格是月首所在周的周一', () => {
|
||||
// 2026-09-01 是周二 → 首格应是 08-31(周一)
|
||||
const cells = monthGrid(new Date(2026, 8, 15));
|
||||
expect(dayKey(cells[0])).toBe('2026-08-31');
|
||||
});
|
||||
|
||||
it('格子连续无空洞', () => {
|
||||
const cells = monthGrid(new Date(2026, 8, 15));
|
||||
for (let i = 1; i < cells.length; i++) {
|
||||
const diff = cells[i].getTime() - cells[i - 1].getTime();
|
||||
// 允许夏令时造成的 ±1 小时偏差
|
||||
expect(diff).toBeGreaterThanOrEqual(23 * 3600_000);
|
||||
expect(diff).toBeLessThanOrEqual(25 * 3600_000);
|
||||
}
|
||||
});
|
||||
|
||||
it('包含整个当月', () => {
|
||||
const anchor = new Date(2026, 8, 15);
|
||||
const keys = monthGrid(anchor).map(dayKey);
|
||||
expect(keys).toContain('2026-09-01');
|
||||
expect(keys).toContain('2026-09-30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekDays', () => {
|
||||
it('给 7 天,周一起头', () => {
|
||||
const days = weekDays(new Date(2026, 8, 3)); // 周四
|
||||
expect(days).toHaveLength(7);
|
||||
expect(dayKey(days[0])).toBe('2026-08-31');
|
||||
expect(dayKey(days[6])).toBe('2026-09-06');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remindAt', () => {
|
||||
it('提前 30 分钟', () => {
|
||||
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 30 });
|
||||
expect(remindAt(e).toISOString()).toBe('2026-09-03T06:00:00.000Z');
|
||||
});
|
||||
|
||||
it('remind_before 为 0 时就是事件时间', () => {
|
||||
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 0 });
|
||||
expect(remindAt(e).toISOString()).toBe('2026-09-03T06:30:00.000Z');
|
||||
});
|
||||
|
||||
it('提前一整天', () => {
|
||||
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 1440 });
|
||||
expect(remindAt(e).toISOString()).toBe('2026-09-02T06:30:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bucketByDay', () => {
|
||||
it('按本地日期分桶,不用 UTC 前缀', () => {
|
||||
// 东八区 22:00 → UTC 是前一天 14:00。用 toISOString().slice(0,10) 会归错天
|
||||
const e = ev({ event_time: '2026-09-03T22:00:00+08:00' });
|
||||
const m = bucketByDay([e]);
|
||||
expect([...m.keys()]).toEqual(['2026-09-03']);
|
||||
});
|
||||
|
||||
it('同一天内按时间正序', () => {
|
||||
const late = ev({ event_time: '2026-09-03T18:00:00+08:00', title: '晚' });
|
||||
const early = ev({ event_time: '2026-09-03T09:00:00+08:00', title: '早' });
|
||||
const m = bucketByDay([late, early]);
|
||||
expect(m.get('2026-09-03')!.map(x => x.title)).toEqual(['早', '晚']);
|
||||
});
|
||||
|
||||
it('同刻事件用 event_id 兜底定序', () => {
|
||||
const ts = '2026-09-03T09:00:00+08:00';
|
||||
const a = ev({ event_id: 'aaa', event_time: ts });
|
||||
const z = ev({ event_id: 'zzz', event_time: ts });
|
||||
const m1 = bucketByDay([a, z]).get('2026-09-03')!.map(x => x.event_id);
|
||||
const m2 = bucketByDay([z, a]).get('2026-09-03')!.map(x => x.event_id);
|
||||
expect(m1).toEqual(m2);
|
||||
expect(m1).toEqual(['aaa', 'zzz']);
|
||||
});
|
||||
|
||||
it('时间解析失败的脏数据被跳过而不是让整个视图空白', () => {
|
||||
const bad = ev({ event_time: '不是时间' });
|
||||
const good = ev({ event_time: '2026-09-03T09:00:00+08:00' });
|
||||
const m = bucketByDay([bad, good]);
|
||||
expect([...m.keys()]).toEqual(['2026-09-03']);
|
||||
});
|
||||
|
||||
it('空输入给空 Map', () => {
|
||||
expect(bucketByDay([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderReminder', () => {
|
||||
it('替换三个变量', () => {
|
||||
const out = renderReminder('【{title}】{time} — {description}', {
|
||||
title: '发布评审',
|
||||
description: '看 llmsproxy 的部署脚本',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('【发布评审】2026-09-03 14:30 — 看 llmsproxy 的部署脚本');
|
||||
});
|
||||
|
||||
it('同一变量出现多次全部替换', () => {
|
||||
// replaceAll 而非 replace:后者只换第一个
|
||||
const out = renderReminder('{title} / {title}', {
|
||||
title: 'X',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('X / X');
|
||||
});
|
||||
|
||||
it('description 缺失时替换成空串而不是 undefined', () => {
|
||||
const out = renderReminder('{description}|', {
|
||||
title: 'T',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('|');
|
||||
});
|
||||
|
||||
it('没有变量的模板原样返回', () => {
|
||||
const out = renderReminder('纯文本提醒', {
|
||||
title: 'T',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('纯文本提醒');
|
||||
});
|
||||
|
||||
it('时间解析失败时回退到原始串', () => {
|
||||
const out = renderReminder('{time}', { title: 'T', event_time: '坏时间' });
|
||||
expect(out).toBe('坏时间');
|
||||
});
|
||||
});
|
||||
|
||||
describe('datetime-local 往返', () => {
|
||||
it('toLocalInput 给本地时区的 YYYY-MM-DDTHH:mm', () => {
|
||||
const d = new Date(2026, 8, 3, 14, 30);
|
||||
expect(toLocalInput(d)).toBe('2026-09-03T14:30');
|
||||
});
|
||||
|
||||
it('补零', () => {
|
||||
const d = new Date(2026, 0, 5, 9, 5);
|
||||
expect(toLocalInput(d)).toBe('2026-01-05T09:05');
|
||||
});
|
||||
|
||||
it('fromLocalInput 按本地时区解析(人写的就是本地时间)', () => {
|
||||
const iso = fromLocalInput('2026-09-03T14:30');
|
||||
// 结果应与本地构造的 Date 一致
|
||||
expect(iso).toBe(new Date(2026, 8, 3, 14, 30).toISOString());
|
||||
});
|
||||
|
||||
it('往返不丢分钟', () => {
|
||||
const original = new Date(2026, 8, 3, 14, 30);
|
||||
expect(toLocalInput(new Date(fromLocalInput(toLocalInput(original))))).toBe('2026-09-03T14:30');
|
||||
});
|
||||
|
||||
it('非法输入给空串', () => {
|
||||
expect(fromLocalInput('')).toBe('');
|
||||
expect(fromLocalInput('坏值')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('人类可读描述', () => {
|
||||
it('重复规则', () => {
|
||||
expect(describeRecurrence(ev({ recurrence: 'none' }))).toBe('不重复');
|
||||
expect(describeRecurrence(ev({ recurrence: 'daily' }))).toBe('每天');
|
||||
expect(describeRecurrence(ev({ recurrence: 'weekly' }))).toBe('每周');
|
||||
expect(describeRecurrence(ev({ recurrence: 'monthly' }))).toBe('每月');
|
||||
});
|
||||
|
||||
it('带终止时间', () => {
|
||||
const e = ev({ recurrence: 'daily', recurrence_end: '2026-12-31T00:00:00+08:00' });
|
||||
expect(describeRecurrence(e)).toBe('每天,至 2026-12-31');
|
||||
});
|
||||
|
||||
it('none 时忽略 recurrence_end', () => {
|
||||
const e = ev({ recurrence: 'none', recurrence_end: '2026-12-31T00:00:00+08:00' });
|
||||
expect(describeRecurrence(e)).toBe('不重复');
|
||||
});
|
||||
|
||||
it('提前提醒', () => {
|
||||
expect(describeRemindBefore(0)).toBe('到点提醒');
|
||||
expect(describeRemindBefore(15)).toBe('提前 15 分钟');
|
||||
expect(describeRemindBefore(60)).toBe('提前 1 小时');
|
||||
expect(describeRemindBefore(120)).toBe('提前 2 小时');
|
||||
expect(describeRemindBefore(90)).toBe('提前 1 小时 30 分钟');
|
||||
expect(describeRemindBefore(1440)).toBe('提前 24 小时');
|
||||
});
|
||||
});
|
||||
268
client/electron/test/components/lunar.test.tsx
Normal file
268
client/electron/test/components/lunar.test.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
fromSolar, toSolar, leapMonth, daysInMonth,
|
||||
addLunarMonths, addLunarYears,
|
||||
formatLunarFull, formatLunarShort, cellLunarLabel, lunarDayName,
|
||||
formatSolarWithLunar, isLunarRecurrence,
|
||||
nextOccurrence, addSolarMonthsClamped, upcomingOccurrences,
|
||||
describeRecurrenceRule
|
||||
} from '../../src/lib/lunar';
|
||||
|
||||
describe('公历 ↔ 农历', () => {
|
||||
it('已知锚点', () => {
|
||||
const d = fromSolar(new Date(2026, 8, 3));
|
||||
expect(d).toEqual({ year: 2026, month: 7, day: 22 });
|
||||
});
|
||||
|
||||
it('闰月用负数月份表示', () => {
|
||||
// 2025 有闰六月
|
||||
expect(leapMonth(2025)).toBe(6);
|
||||
const d = fromSolar(new Date(2025, 6, 25)); // 2025-07-25
|
||||
expect(d.month).toBe(-6);
|
||||
expect(d.day).toBe(1);
|
||||
});
|
||||
|
||||
it('无闰月的年份返回 0', () => {
|
||||
expect(leapMonth(2026)).toBe(0);
|
||||
expect(leapMonth(2027)).toBe(0);
|
||||
});
|
||||
|
||||
it('往返不丢日期', () => {
|
||||
for (const [y, m, dd] of [[2026, 8, 3], [2027, 1, 14], [2025, 6, 25]] as const) {
|
||||
const solar = new Date(y, m, dd);
|
||||
const lunar = fromSolar(solar);
|
||||
const back = toSolar(lunar, 9, 30);
|
||||
expect(back).not.toBeNull();
|
||||
expect(back!.clamped).toBe(false);
|
||||
expect(back!.date.getFullYear()).toBe(y);
|
||||
expect(back!.date.getMonth()).toBe(m);
|
||||
expect(back!.date.getDate()).toBe(dd);
|
||||
// 时钟原样带过去(农历只定义到「日」)
|
||||
expect(back!.date.getHours()).toBe(9);
|
||||
expect(back!.date.getMinutes()).toBe(30);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('短月份夹取', () => {
|
||||
it('2027 农历九月只有 29 天', () => {
|
||||
expect(daysInMonth(2027, 9)).toBe(29);
|
||||
expect(daysInMonth(2026, 1)).toBe(30);
|
||||
});
|
||||
|
||||
// 库在 Lunar.fromYmd(2027,9,30) 上直接 throw。
|
||||
// 「每月农历三十」必然撞上 29 天的月份,不夹住就是整片白屏。
|
||||
it('要三十而该月只有廿九时夹到廿九,不抛错', () => {
|
||||
const r = toSolar({ year: 2027, month: 9, day: 30 }, 9, 0);
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.clamped).toBe(true);
|
||||
// 夹取后必须仍在同一个农历月内(滚到下月初一是错的)
|
||||
expect(fromSolar(r!.date).month).toBe(9);
|
||||
expect(fromSolar(r!.date).day).toBe(29);
|
||||
});
|
||||
|
||||
it('不存在的闰月返回 null 而不是猜一个日子', () => {
|
||||
expect(daysInMonth(2026, -6)).toBe(0);
|
||||
expect(toSolar({ year: 2026, month: -6, day: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
it('非法日返回 null', () => {
|
||||
expect(toSolar({ year: 2026, month: 1, day: 0 })).toBeNull();
|
||||
expect(toSolar({ year: 2026, month: 13, day: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('农历推进', () => {
|
||||
it('加月跨年', () => {
|
||||
expect(addLunarMonths({ year: 2026, month: 12, day: 5 }, 1)).toEqual({ year: 2027, month: 1, day: 5 });
|
||||
});
|
||||
|
||||
it('推 12 次回到次年同月', () => {
|
||||
expect(addLunarMonths({ year: 2026, month: 7, day: 22 }, 12))
|
||||
.toEqual({ year: 2027, month: 7, day: 22 });
|
||||
});
|
||||
|
||||
// 「每月十五」的期望是一年 12 次,把闰月算进去会让闰年多一次
|
||||
it('从闰月出发先归正月份', () => {
|
||||
expect(addLunarMonths({ year: 2025, month: -6, day: 15 }, 1).month).toBe(7);
|
||||
});
|
||||
|
||||
it('加年保持月日', () => {
|
||||
expect(addLunarYears({ year: 2026, month: 7, day: 22 }, 1))
|
||||
.toEqual({ year: 2027, month: 7, day: 22 });
|
||||
});
|
||||
|
||||
// 静默让重复事件消失比退回正月份更糟
|
||||
it('目标年无同一闰月时退回正月份', () => {
|
||||
expect(addLunarYears({ year: 2025, month: -6, day: 15 }, 1))
|
||||
.toEqual({ year: 2026, month: 6, day: 15 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('中文表示', () => {
|
||||
it('完整表示', () => {
|
||||
expect(formatLunarFull({ year: 2026, month: 7, day: 22 })).toBe('二〇二六年七月廿二');
|
||||
});
|
||||
|
||||
it('闰月带「闰」字', () => {
|
||||
expect(formatLunarFull({ year: 2025, month: -6, day: 1 })).toBe('二〇二五年闰六月初一');
|
||||
});
|
||||
|
||||
it('短表示去掉年份', () => {
|
||||
expect(formatLunarShort({ year: 2026, month: 7, day: 22 })).toBe('七月廿二');
|
||||
});
|
||||
|
||||
// 日名有五种前缀形态(初一/十一/二十/廿一/三十),
|
||||
// 用正则截取时「二十」曾被漏掉
|
||||
it('日名查表覆盖全部 30 天', () => {
|
||||
const names = Array.from({ length: 30 }, (_, i) => lunarDayName(i + 1));
|
||||
expect(names[0]).toBe('初一');
|
||||
expect(names[9]).toBe('初十');
|
||||
expect(names[10]).toBe('十一');
|
||||
expect(names[19]).toBe('二十');
|
||||
expect(names[20]).toBe('廿一');
|
||||
expect(names[29]).toBe('三十');
|
||||
expect(new Set(names).size).toBe(30); // 无重复
|
||||
expect(names.every(n => n.length === 2)).toBe(true);
|
||||
});
|
||||
|
||||
it('格子标记:初一显示月名,其余显示日名', () => {
|
||||
// 2026-08-13 是农历七月初一
|
||||
const firstDay = toSolar({ year: 2026, month: 7, day: 1 })!.date;
|
||||
expect(cellLunarLabel(firstDay)).toBe('七月');
|
||||
expect(cellLunarLabel(new Date(2026, 8, 3))).toBe('廿二');
|
||||
});
|
||||
|
||||
it('闰月初一的格子标记带「闰」', () => {
|
||||
const leapFirst = toSolar({ year: 2025, month: -6, day: 1 })!.date;
|
||||
expect(cellLunarLabel(leapFirst)).toBe('闰六月');
|
||||
});
|
||||
|
||||
it('公历+农历合并显示', () => {
|
||||
expect(formatSolarWithLunar(new Date(2026, 8, 3))).toBe('2026-09-03(农历七月廿二)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextOccurrence 与后端 repo.NextOccurrence 对齐', () => {
|
||||
const base = new Date(2026, 8, 3, 9, 30);
|
||||
|
||||
it('公历三种', () => {
|
||||
expect(nextOccurrence('daily', base)!.getDate()).toBe(4);
|
||||
expect(nextOccurrence('weekly', base)!.getDate()).toBe(10);
|
||||
expect(nextOccurrence('monthly', base)!.getMonth()).toBe(9);
|
||||
});
|
||||
|
||||
it('公历每年', () => {
|
||||
const n = nextOccurrence('yearly', base)!;
|
||||
expect(n.getFullYear()).toBe(2027);
|
||||
expect(n.getMonth()).toBe(8);
|
||||
expect(n.getDate()).toBe(3);
|
||||
});
|
||||
|
||||
it('none 与未知值给 null', () => {
|
||||
expect(nextOccurrence('none', base)).toBeNull();
|
||||
expect(nextOccurrence('每隔一个蓝月亮', base)).toBeNull();
|
||||
});
|
||||
|
||||
it('时钟保留', () => {
|
||||
for (const r of ['daily', 'weekly', 'monthly', 'yearly', 'lunar_monthly', 'lunar_yearly']) {
|
||||
const n = nextOccurrence(r, base);
|
||||
expect(n).not.toBeNull();
|
||||
expect(n!.getHours()).toBe(9);
|
||||
expect(n!.getMinutes()).toBe(30);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// setMonth 的溢出(3月31日 +1月 = 5月1日)会永久改变规则:
|
||||
// 31 日的事件在 2 月变成 3 月 3 日,然后从此每月 3 日提醒
|
||||
describe('公历月末夹取', () => {
|
||||
it.each([
|
||||
['2026-01-31', 1, '2026-02-28'],
|
||||
['2026-03-31', 1, '2026-04-30'],
|
||||
['2028-01-31', 1, '2028-02-29'],
|
||||
['2028-02-29', 12, '2029-02-28'],
|
||||
['2026-01-15', 1, '2026-02-15']
|
||||
])('%s + %i 月 → %s', (from, n, want) => {
|
||||
const [y, m, d] = from.split('-').map(Number);
|
||||
const got = addSolarMonthsClamped(new Date(y, m - 1, d), n);
|
||||
const pad = (x: number) => String(x).padStart(2, '0');
|
||||
expect(`${got.getFullYear()}-${pad(got.getMonth() + 1)}-${pad(got.getDate())}`).toBe(want);
|
||||
});
|
||||
});
|
||||
|
||||
describe('农历重复的公历漂移', () => {
|
||||
it('农历月间隔在 29~30 天之间浮动,不是固定值', () => {
|
||||
let cur = new Date(2026, 8, 3, 9, 0);
|
||||
const gaps = new Set<number>();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const next = nextOccurrence('lunar_monthly', cur)!;
|
||||
expect(next.getTime()).toBeGreaterThan(cur.getTime());
|
||||
gaps.add(Math.round((next.getTime() - cur.getTime()) / 86400000));
|
||||
// 农历「日」保持不变
|
||||
expect(fromSolar(next).day).toBe(22);
|
||||
cur = next;
|
||||
}
|
||||
expect(gaps.size).toBeGreaterThan(1);
|
||||
for (const g of gaps) expect(g).toBeGreaterThanOrEqual(28);
|
||||
for (const g of gaps) expect(g).toBeLessThanOrEqual(31);
|
||||
});
|
||||
|
||||
// 这是农历规则存在的理由:用公历 yearly 日子会固定,
|
||||
// 与「过农历生日/祭日」的期望不符
|
||||
it('农历年推进时公历月日每年都变', () => {
|
||||
let cur = new Date(2026, 8, 3, 9, 0);
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const next = nextOccurrence('lunar_yearly', cur)!;
|
||||
const d = fromSolar(next);
|
||||
expect(d.month).toBe(7);
|
||||
expect(d.day).toBe(22);
|
||||
seen.add(`${next.getMonth() + 1}-${next.getDate()}`);
|
||||
cur = next;
|
||||
}
|
||||
expect(seen.size).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upcomingOccurrences', () => {
|
||||
it('给出连续递增的 n 次', () => {
|
||||
const list = upcomingOccurrences('lunar_monthly', new Date(2026, 8, 3, 9, 0), 3);
|
||||
expect(list).toHaveLength(3);
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect(list[i].getTime()).toBeGreaterThan(list[i - 1].getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it('不重复时给空数组', () => {
|
||||
expect(upcomingOccurrences('none', new Date(), 3)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeRecurrenceRule', () => {
|
||||
it('公历规则', () => {
|
||||
expect(describeRecurrenceRule('none')).toBe('不重复');
|
||||
expect(describeRecurrenceRule('daily')).toBe('每天');
|
||||
expect(describeRecurrenceRule('weekly')).toBe('每周');
|
||||
expect(describeRecurrenceRule('monthly')).toBe('每月');
|
||||
expect(describeRecurrenceRule('yearly')).toBe('每年');
|
||||
});
|
||||
|
||||
it('农历规则把农历日子写出来', () => {
|
||||
const t = new Date(2026, 8, 3);
|
||||
expect(describeRecurrenceRule('lunar_monthly', t)).toBe('每农历月廿二');
|
||||
expect(describeRecurrenceRule('lunar_yearly', t)).toBe('每年农历七月廿二');
|
||||
});
|
||||
|
||||
it('无事件时间时退回泛化描述', () => {
|
||||
expect(describeRecurrenceRule('lunar_monthly')).toBe('每农历月同一日');
|
||||
expect(describeRecurrenceRule('lunar_yearly')).toBe('每农历年同月同日');
|
||||
});
|
||||
|
||||
it('isLunarRecurrence', () => {
|
||||
expect(isLunarRecurrence('lunar_monthly')).toBe(true);
|
||||
expect(isLunarRecurrence('lunar_yearly')).toBe(true);
|
||||
expect(isLunarRecurrence('monthly')).toBe(false);
|
||||
expect(isLunarRecurrence('yearly')).toBe(false);
|
||||
});
|
||||
});
|
||||
333
client/electron/test/components/mailGroups.test.tsx
Normal file
333
client/electron/test/components/mailGroups.test.tsx
Normal file
@ -0,0 +1,333 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
groupMailsBySession,
|
||||
isFlatGroup,
|
||||
isPendingPermission,
|
||||
splitByPermission,
|
||||
countPendingPermissions,
|
||||
groupPermissions
|
||||
} from '../../src/lib/mailGroups';
|
||||
import type { Mail } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 收件箱会话分组 + 授权请求独立分组。
|
||||
*
|
||||
* 这些用例锁的是生产实测过的形状:一个会话独占 17 封权限邮件,
|
||||
* 把另外两个会话的信挤出视野。修法有两层 ——
|
||||
* 权限请求整体移出收件箱(各归各的导航项),剩下的普通邮件按会话折叠。
|
||||
*/
|
||||
|
||||
let seq = 0;
|
||||
function mail(over: Partial<Mail> = {}): Mail {
|
||||
seq += 1;
|
||||
return {
|
||||
mail_id: `m${String(seq).padStart(3, '0')}`,
|
||||
session_id: 's1',
|
||||
parent_mail_id: null,
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'jianf',
|
||||
to_workspace: '',
|
||||
cc_list: [],
|
||||
subject: `主题 ${seq}`,
|
||||
body: '正文',
|
||||
mail_type: 'normal',
|
||||
permission_options: null,
|
||||
permission_result: null,
|
||||
status: 'read',
|
||||
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
|
||||
from_human: false,
|
||||
to_human: true,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
function perm(over: Partial<Mail> = {}): Mail {
|
||||
return mail({ mail_type: 'permission_request', ...over });
|
||||
}
|
||||
|
||||
describe('groupMailsBySession', () => {
|
||||
it('把同一会话的多封邮件折成一组', () => {
|
||||
const groups = groupMailsBySession([
|
||||
mail({ session_id: 'a' }),
|
||||
mail({ session_id: 'a' }),
|
||||
mail({ session_id: 'a' }),
|
||||
mail({ session_id: 'b' })
|
||||
]);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.find(g => g.sessionId === 'a')!.mails).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('组内按时间倒序,最新那封做组头', () => {
|
||||
const old = mail({ session_id: 'a', created_at: '2026-09-03T08:00:00Z', subject: '旧' });
|
||||
const mid = mail({ session_id: 'a', created_at: '2026-09-03T09:00:00Z', subject: '中' });
|
||||
const now = mail({ session_id: 'a', created_at: '2026-09-03T10:00:00Z', subject: '新' });
|
||||
const [g] = groupMailsBySession([mid, old, now]); // 故意乱序传入
|
||||
expect(g.mails.map(m => m.subject)).toEqual(['新', '中', '旧']);
|
||||
expect(g.latest.subject).toBe('新');
|
||||
// 组标题取最新一封:会话主题随任务推进被改写,最新的最贴切
|
||||
expect(g.subject).toBe('新');
|
||||
});
|
||||
|
||||
it('组之间按最新邮件时间倒序', () => {
|
||||
const groups = groupMailsBySession([
|
||||
mail({ session_id: 'stale', created_at: '2026-09-01T10:00:00Z' }),
|
||||
mail({ session_id: 'fresh', created_at: '2026-09-03T10:00:00Z' }),
|
||||
mail({ session_id: 'mid', created_at: '2026-09-02T10:00:00Z' })
|
||||
]);
|
||||
expect(groups.map(g => g.sessionId)).toEqual(['fresh', 'mid', 'stale']);
|
||||
});
|
||||
|
||||
it('同一时刻用 mail_id 兜底定序(SQLite 时间戳精度有限)', () => {
|
||||
const ts = '2026-09-03T10:00:00Z';
|
||||
const a = mail({ mail_id: 'aaa', session_id: 's', created_at: ts });
|
||||
const z = mail({ mail_id: 'zzz', session_id: 's', created_at: ts });
|
||||
const [g1] = groupMailsBySession([a, z]);
|
||||
const [g2] = groupMailsBySession([z, a]);
|
||||
// 两种输入顺序必须给出同一结果,否则「刷新一次顺序就变了」
|
||||
expect(g1.mails.map(m => m.mail_id)).toEqual(g2.mails.map(m => m.mail_id));
|
||||
expect(g1.mails[0].mail_id).toBe('zzz');
|
||||
});
|
||||
|
||||
it('统计未读数', () => {
|
||||
const [g] = groupMailsBySession([
|
||||
mail({ session_id: 's', status: 'unread' }),
|
||||
mail({ session_id: 's', status: 'unread' }),
|
||||
mail({ session_id: 's', status: 'read' })
|
||||
]);
|
||||
expect(g.unreadCount).toBe(2);
|
||||
});
|
||||
|
||||
it('别名取自最新一封;无别名给空串而不是 undefined', () => {
|
||||
const [withAlias] = groupMailsBySession([
|
||||
mail({ session_id: 's', session_alias: 'deploy-review' })
|
||||
]);
|
||||
expect(withAlias.alias).toBe('deploy-review');
|
||||
const [without] = groupMailsBySession([mail({ session_id: 's' })]);
|
||||
expect(without.alias).toBe('');
|
||||
});
|
||||
|
||||
it('session_id 缺失的脏数据各自成组而不是挤成一堆', () => {
|
||||
const groups = groupMailsBySession([
|
||||
mail({ mail_id: 'x', session_id: '' }),
|
||||
mail({ mail_id: 'y', session_id: '' })
|
||||
]);
|
||||
// 全归到 '' 这一组会把两封无关的信显示成一个会话
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('created_at 解析失败不会让顺序变得不确定', () => {
|
||||
const bad = mail({ mail_id: 'bad', session_id: 's', created_at: '不是时间' });
|
||||
const good = mail({ mail_id: 'good', session_id: 's', created_at: '2026-09-03T10:00:00Z' });
|
||||
const [g] = groupMailsBySession([bad, good]);
|
||||
// NaN 参与比较恒为 false,会让排序结果取决于原数组顺序
|
||||
expect(g.mails.map(m => m.mail_id)).toEqual(['good', 'bad']);
|
||||
});
|
||||
|
||||
it('不修改入参数组', () => {
|
||||
const input = [
|
||||
mail({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }),
|
||||
mail({ session_id: 's', created_at: '2026-09-03T10:00:00Z' })
|
||||
];
|
||||
const snapshot = input.map(m => m.mail_id);
|
||||
groupMailsBySession(input);
|
||||
expect(input.map(m => m.mail_id)).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('空数组返回空数组', () => {
|
||||
expect(groupMailsBySession([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFlatGroup', () => {
|
||||
it('单封邮件的组平铺显示,不套折叠头', () => {
|
||||
const [g] = groupMailsBySession([mail({ session_id: 'solo' })]);
|
||||
expect(isFlatGroup(g)).toBe(true);
|
||||
});
|
||||
|
||||
it('两封以上才折叠', () => {
|
||||
const [g] = groupMailsBySession([mail({ session_id: 's' }), mail({ session_id: 's' })]);
|
||||
expect(isFlatGroup(g)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitByPermission', () => {
|
||||
it('权限请求与普通邮件分开', () => {
|
||||
const { normal, permissions } = splitByPermission([
|
||||
mail({ subject: '来信' }),
|
||||
perm({ subject: '要跑 bash' }),
|
||||
mail({ subject: '又一封' })
|
||||
]);
|
||||
expect(normal.map(m => m.subject)).toEqual(['来信', '又一封']);
|
||||
expect(permissions.map(m => m.subject)).toEqual(['要跑 bash']);
|
||||
});
|
||||
|
||||
it('保持原有顺序(调用方自己排序)', () => {
|
||||
const a = mail({ mail_id: 'a' });
|
||||
const b = mail({ mail_id: 'b' });
|
||||
expect(splitByPermission([a, b]).normal.map(m => m.mail_id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('空输入给两个空数组而不是 undefined', () => {
|
||||
const r = splitByPermission([]);
|
||||
expect(r.normal).toEqual([]);
|
||||
expect(r.permissions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPendingPermission', () => {
|
||||
it('普通邮件不是权限请求', () => {
|
||||
expect(isPendingPermission(mail())).toBe(false);
|
||||
});
|
||||
|
||||
it('permission_result 为空串或 null 都算未决策', () => {
|
||||
// 后端用 COALESCE(permission_result,'') 归一,两种都会出现
|
||||
expect(isPendingPermission(perm({ permission_result: null }))).toBe(true);
|
||||
expect(isPendingPermission(perm({ permission_result: '' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('有决策结果就不再是待决策', () => {
|
||||
expect(isPendingPermission(perm({ permission_result: '拒绝' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countPendingPermissions', () => {
|
||||
it('只数待决策的那些', () => {
|
||||
const n = countPendingPermissions([
|
||||
perm({ permission_result: null }),
|
||||
perm({ permission_result: '' }),
|
||||
perm({ permission_result: '同意' }),
|
||||
mail()
|
||||
]);
|
||||
expect(n).toBe(2);
|
||||
});
|
||||
|
||||
it('没有待决策时给 0', () => {
|
||||
expect(countPendingPermissions([perm({ permission_result: '同意' })])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupPermissions', () => {
|
||||
it('只收权限请求,普通邮件不进来', () => {
|
||||
const groups = groupPermissions([
|
||||
mail({ session_id: 'a' }),
|
||||
perm({ session_id: 'a' }),
|
||||
perm({ session_id: 'a' })
|
||||
]);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].pending.length + groups[0].settled.length).toBe(2);
|
||||
});
|
||||
|
||||
it('待决策与已决策分开装', () => {
|
||||
const [g] = groupPermissions([
|
||||
perm({ session_id: 's', permission_result: null, subject: '在等' }),
|
||||
perm({ session_id: 's', permission_result: '同意', subject: '批过' }),
|
||||
perm({ session_id: 's', permission_result: '拒绝', subject: '拒过' })
|
||||
]);
|
||||
expect(g.pending.map(m => m.subject)).toEqual(['在等']);
|
||||
expect(g.settled.map(m => m.subject).sort()).toEqual(['批过', '拒过']);
|
||||
});
|
||||
|
||||
it('有待决策的会话排在前面,哪怕它更旧', () => {
|
||||
const groups = groupPermissions([
|
||||
// 刚刚批完的
|
||||
perm({ session_id: 'done', permission_result: '同意', created_at: '2026-09-03T12:00:00Z' }),
|
||||
// 三天前就卡着的 —— 那条 Agent 会话正在那儿等
|
||||
perm({ session_id: 'stuck', permission_result: null, created_at: '2026-08-31T09:00:00Z' })
|
||||
]);
|
||||
expect(groups.map(g => g.sessionId)).toEqual(['stuck', 'done']);
|
||||
});
|
||||
|
||||
it('都有待决策时,卡住更多请求的会话更靠前', () => {
|
||||
const groups = groupPermissions([
|
||||
perm({ session_id: 'one', permission_result: null, created_at: '2026-09-03T12:00:00Z' }),
|
||||
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:00:00Z' }),
|
||||
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:01:00Z' }),
|
||||
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:02:00Z' })
|
||||
]);
|
||||
expect(groups[0].sessionId).toBe('many');
|
||||
});
|
||||
|
||||
it('都无待决策时按最新时间倒序', () => {
|
||||
const groups = groupPermissions([
|
||||
perm({ session_id: 'old', permission_result: '同意', created_at: '2026-09-01T10:00:00Z' }),
|
||||
perm({ session_id: 'new', permission_result: '同意', created_at: '2026-09-03T10:00:00Z' })
|
||||
]);
|
||||
expect(groups.map(g => g.sessionId)).toEqual(['new', 'old']);
|
||||
});
|
||||
|
||||
it('组头带上发起请求的 Agent 与会话工作目录', () => {
|
||||
const [g] = groupPermissions([
|
||||
perm({
|
||||
session_id: 's',
|
||||
from_name: 'dsh',
|
||||
// from_workspace 对 Agent 存的是 **Agent 名**而不是路径(历史遗留)。
|
||||
// 拿它当路径用会在授权页拼出 `dsh@dsh`,而权限请求的
|
||||
// from_workspace 实测是**空串** —— 于是那一行永远不渲染,
|
||||
// 人根本不知道是哪个目录里的哪条线索在请求权限。
|
||||
from_workspace: 'dsh',
|
||||
session_workspace: '/home/program/llmsproxy'
|
||||
})
|
||||
]);
|
||||
// 同名 Agent 在不同目录是不同的活,光有名字判断不了
|
||||
expect(g.agentName).toBe('dsh');
|
||||
// path 必须取 session_workspace(会话的 workspace,权威来源)
|
||||
expect(g.path).toBe('/home/program/llmsproxy');
|
||||
});
|
||||
|
||||
it('组内时间倒序', () => {
|
||||
const [g] = groupPermissions([
|
||||
perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T08:00:00Z', subject: '早' }),
|
||||
perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T10:00:00Z', subject: '晚' })
|
||||
]);
|
||||
expect(g.pending.map(m => m.subject)).toEqual(['晚', '早']);
|
||||
});
|
||||
|
||||
it('没有权限请求时返回空数组', () => {
|
||||
expect(groupPermissions([mail(), mail()])).toEqual([]);
|
||||
});
|
||||
|
||||
it('不修改入参数组', () => {
|
||||
const input = [
|
||||
perm({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }),
|
||||
perm({ session_id: 's', created_at: '2026-09-03T10:00:00Z' })
|
||||
];
|
||||
const snapshot = input.map(m => m.mail_id);
|
||||
groupPermissions(input);
|
||||
expect(input.map(m => m.mail_id)).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('生产实测形状:17 封权限邮件的会话', () => {
|
||||
it('权限请求移出收件箱后,剩下的信按会话分组且不再被淹', () => {
|
||||
const flood: Mail[] = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
flood.push(
|
||||
perm({
|
||||
session_id: 'f3ce62b0',
|
||||
permission_result: i === 16 ? null : '同意',
|
||||
created_at: `2026-09-03T09:${String(i).padStart(2, '0')}:00Z`
|
||||
})
|
||||
);
|
||||
}
|
||||
const realMail = [
|
||||
mail({ session_id: 'fa420c33', created_at: '2026-09-03T10:00:00Z', subject: '进展汇报' }),
|
||||
mail({ session_id: 'c2db6b62', created_at: '2026-09-03T11:00:00Z', subject: '需要确认' })
|
||||
];
|
||||
const inbox = [...flood, ...realMail];
|
||||
|
||||
// 收件箱侧:权限请求整体不进来,只剩两封真信
|
||||
const { normal, permissions } = splitByPermission(inbox);
|
||||
expect(normal).toHaveLength(2);
|
||||
expect(permissions).toHaveLength(17);
|
||||
const inboxGroups = groupMailsBySession(normal);
|
||||
expect(inboxGroups.map(g => g.subject)).toEqual(['需要确认', '进展汇报']);
|
||||
|
||||
// 授权侧:17 封折成 1 组,只有 1 个在等人
|
||||
const permGroups = groupPermissions(permissions);
|
||||
expect(permGroups).toHaveLength(1);
|
||||
expect(permGroups[0].pending).toHaveLength(1);
|
||||
expect(permGroups[0].settled).toHaveLength(16);
|
||||
expect(countPendingPermissions(permissions)).toBe(1);
|
||||
});
|
||||
});
|
||||
421
client/electron/test/components/replyTarget.test.tsx
Normal file
421
client/electron/test/components/replyTarget.test.tsx
Normal file
@ -0,0 +1,421 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatAddress,
|
||||
mailCounterpart,
|
||||
sessionCounterpart,
|
||||
sessionReplyTarget,
|
||||
mailReplyTarget,
|
||||
replyAllCC,
|
||||
participantAddress
|
||||
} from '../../src/lib/replyTarget';
|
||||
import type { Mail, Session } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 「这封回复该发给谁」。
|
||||
*
|
||||
* 锁的是一次生产事故:判据写死 `from_name === 'human'`(单用户时代的遗留),
|
||||
* 多用户下登录名是 jianf,判据恒为假 → 对端取成 from_name(自己)→ 信发给自己。
|
||||
* 数据库里的链条:
|
||||
* pi → jianf 权限请求
|
||||
* jianf → pi Re: 权限请求 (对的,锚点是 pi 发来的)
|
||||
* pi → jianf 权限请求
|
||||
* jianf → jianf Re: Re: 权限请求 (错的,锚点是自己发的)
|
||||
*/
|
||||
|
||||
let seq = 0;
|
||||
function mail(over: Partial<Mail> = {}): Mail {
|
||||
seq += 1;
|
||||
return {
|
||||
mail_id: `m${String(seq).padStart(3, '0')}`,
|
||||
session_id: 's1',
|
||||
parent_mail_id: null,
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home/program/llmsproxy',
|
||||
to_name: 'jianf',
|
||||
to_workspace: '',
|
||||
cc_list: [],
|
||||
subject: '主题',
|
||||
body: '正文',
|
||||
mail_type: 'normal',
|
||||
permission_options: null,
|
||||
permission_result: null,
|
||||
status: 'read',
|
||||
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
|
||||
// 默认值:会话工作目录、发件方是 Agent、收件方是人 ——
|
||||
// 对应「pi 在 /home/program/llmsproxy 干活 → jianf」这条最常见的信
|
||||
session_workspace: '/home/program/llmsproxy',
|
||||
from_human: false,
|
||||
to_human: true,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
const session = (alias: string | null): Session => ({
|
||||
session_id: 's1',
|
||||
session_alias: alias,
|
||||
from_agent: 'jianf',
|
||||
subject: '关于llmsproxy工程的联合审查',
|
||||
status: 'active',
|
||||
created_at: '2026-09-03T09:00:00Z',
|
||||
updated_at: '2026-09-03T10:00:00Z'
|
||||
});
|
||||
|
||||
describe('formatAddress', () => {
|
||||
it('三段齐全', () => {
|
||||
expect(formatAddress('pi', '/home', 'deploy')).toBe('pi@/home.deploy');
|
||||
});
|
||||
it('无会话段时省略', () => {
|
||||
expect(formatAddress('pi', '/home')).toBe('pi@/home');
|
||||
expect(formatAddress('pi', '/home', null)).toBe('pi@/home');
|
||||
});
|
||||
it('path 与 session 都为空时返回裸名字(裸名字 = 默认会话)', () => {
|
||||
expect(formatAddress('jianf', '')).toBe('jianf');
|
||||
});
|
||||
it('path 为空但有 session 时必须保留 @', () => {
|
||||
// `jianf@.任务` 能被 ParseAddress 还原(按最后一个 . 切分);
|
||||
// 漏掉 @ 的 `jianf.任务` 会被整串当成名字 —— 那是个不存在的 Agent
|
||||
expect(formatAddress('jianf', '', '任务')).toBe('jianf@.任务');
|
||||
});
|
||||
it('名字为空给空串而不是拼出 @', () => {
|
||||
expect(formatAddress('', '/home')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailCounterpart', () => {
|
||||
it('别人发来的 → 回给发件人', () => {
|
||||
const m = mail({ from_name: 'pi', to_name: 'jianf' });
|
||||
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy', isHuman: false });
|
||||
});
|
||||
|
||||
it('我发出的 → 回给收件人(不是回给自己)', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_human: true,
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_human: false,
|
||||
to_workspace: '/home/program/llmsproxy'
|
||||
});
|
||||
// 这一条就是生产 bug:原代码在这里返回 jianf
|
||||
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy', isHuman: false });
|
||||
});
|
||||
|
||||
it('登录名未知时退化为回给发件人,不会回给自己', () => {
|
||||
const m = mail({ from_name: 'pi', to_name: 'jianf' });
|
||||
expect(mailCounterpart(m, '').name).toBe('pi');
|
||||
});
|
||||
|
||||
it('不把 human 当特殊值', () => {
|
||||
// 老判据写死 human;现在它只是个普通名字
|
||||
const m = mail({ from_name: 'human', to_name: 'pi' });
|
||||
expect(mailCounterpart(m, 'jianf').name).toBe('human');
|
||||
expect(mailCounterpart(m, 'human').name).toBe('pi');
|
||||
});
|
||||
|
||||
it('path 从 session_workspace 取,不用 from_workspace(后者存的是 Agent 名)', () => {
|
||||
// from_workspace 存的是 Agent 名而不是路径(历史遗留),
|
||||
// 从 session_workspace 取路径才不会拼出 dsh@dsh
|
||||
const m = mail({
|
||||
from_name: 'dsh',
|
||||
from_workspace: 'dsh', // Agent 名,不是路径
|
||||
to_name: 'jianf',
|
||||
to_human: true,
|
||||
session_workspace: '/home/program/webui4frpc'
|
||||
});
|
||||
const peer = mailCounterpart(m, 'jianf');
|
||||
expect(peer.path).toBe('/home/program/webui4frpc');
|
||||
expect(peer.path).not.toBe('dsh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionCounterpart', () => {
|
||||
it('按会话定对端,不受最后一封是谁发的影响', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '2026-09-03T09:00:00Z' }),
|
||||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' }),
|
||||
// 最后一封是我自己发的 —— 原代码在这里会把自己当对端
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true, created_at: '2026-09-03T10:00:00Z' })
|
||||
];
|
||||
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
|
||||
});
|
||||
|
||||
it('取首个非我参与方:后来被抄送进来的第三方不抢位置', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '2026-09-03T09:00:00Z' }),
|
||||
mail({ from_name: 'dsh', from_workspace: '/opt', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
|
||||
];
|
||||
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
|
||||
});
|
||||
|
||||
it('补上首次出现时缺失的 path', () => {
|
||||
const mails = [
|
||||
// 首封的 session_workspace 是空的
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, session_workspace: '', created_at: '2026-09-03T09:00:00Z' }),
|
||||
// 后一封才带上目录
|
||||
mail({ from_name: 'pi', from_workspace: '/home/program/llmsproxy', to_name: 'jianf', session_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T09:30:00Z' })
|
||||
];
|
||||
// 同名 Agent 在不同目录是不同的活,path 不能丢
|
||||
expect(sessionCounterpart(mails, 'jianf')).toEqual({
|
||||
name: 'pi',
|
||||
path: '/home/program/llmsproxy',
|
||||
isHuman: false
|
||||
});
|
||||
});
|
||||
|
||||
it('同刻邮件用 mail_id 定序,结果稳定', () => {
|
||||
const ts = '2026-09-03T09:00:00Z';
|
||||
const a = mail({ mail_id: 'aaa', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: ts });
|
||||
const z = mail({ mail_id: 'zzz', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'dsh', to_human: false, created_at: ts });
|
||||
expect(sessionCounterpart([a, z], 'jianf')?.name).toBe(sessionCounterpart([z, a], 'jianf')?.name);
|
||||
});
|
||||
|
||||
it('全是自己的会话返回 null 而不是自己', () => {
|
||||
const mails = [mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true })];
|
||||
expect(sessionCounterpart(mails, 'jianf')).toBeNull();
|
||||
});
|
||||
|
||||
it('空会话返回 null', () => {
|
||||
expect(sessionCounterpart([], 'jianf')).toBeNull();
|
||||
});
|
||||
|
||||
it('created_at 解析失败不影响确定性', () => {
|
||||
const bad = mail({ mail_id: 'bad', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '不是时间' });
|
||||
const good = mail({ mail_id: 'good', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'dsh', to_human: false, created_at: '2026-09-03T09:00:00Z' });
|
||||
expect(sessionCounterpart([bad, good], 'jianf')?.name).toBe(
|
||||
sessionCounterpart([good, bad], 'jianf')?.name
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionReplyTarget', () => {
|
||||
it('带上会话别名:不带会落到该 Agent 的默认会话', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, to_workspace: '/home/program/llmsproxy' })
|
||||
];
|
||||
expect(sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf')).toBe(
|
||||
'pi@/home/program/llmsproxy.pi-关于llmsproxy工程的联合审查'
|
||||
);
|
||||
});
|
||||
|
||||
it('会话未命名时省略会话段', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, session_workspace: '/home' })
|
||||
];
|
||||
expect(sessionReplyTarget(mails, session(null), 'jianf')).toBe('pi@/home');
|
||||
});
|
||||
|
||||
it('生产链条重现:回复自己发的那封仍指向 pi', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:53:12Z' }),
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, to_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T10:53:39Z' }),
|
||||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:55:13Z' }),
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true, created_at: '2026-09-03T10:56:34Z' })
|
||||
];
|
||||
const target = sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf');
|
||||
expect(target.startsWith('pi@')).toBe(true);
|
||||
expect(target.startsWith('jianf@')).toBe(false);
|
||||
});
|
||||
|
||||
it('找不到对端时给空串(调用方据此禁用发送)', () => {
|
||||
expect(sessionReplyTarget([], session('x'), 'jianf')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailReplyTarget', () => {
|
||||
it('单封视图带上该封的会话别名', () => {
|
||||
const m = mail({ from_name: 'pi', to_name: 'jianf', session_alias: 'deploy-review' });
|
||||
expect(mailReplyTarget(m, 'jianf')).toBe('pi@/home/program/llmsproxy.deploy-review');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replyAllCC', () => {
|
||||
it('去掉自己与主收件人', () => {
|
||||
const m = mail({
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'jianf',
|
||||
to_workspace: '',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
|
||||
});
|
||||
// 主收件人是 pi(回复对象),自己是 jianf —— 都不该出现在抄送里
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
|
||||
});
|
||||
|
||||
it('自己是 jianf 时不会把自己抄送进去(老判据只挡 human)', () => {
|
||||
const m = mail({ from_name: 'pi', from_workspace: '/home', to_name: 'jianf', to_workspace: '' });
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual([]);
|
||||
});
|
||||
|
||||
it('cc_list 取 raw 保留会话段', () => {
|
||||
const m = mail({
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'jianf',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'audit', raw: 'dsh@/opt.audit' }]
|
||||
});
|
||||
// 重新拼 name@path 会丢掉 .audit
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.audit']);
|
||||
});
|
||||
|
||||
it('同一个人既在 to 又在 cc 时只出现一次', () => {
|
||||
const m = mail({
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'dsh',
|
||||
to_human: false,
|
||||
to_workspace: '/opt',
|
||||
session_workspace: '/opt',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* ─── 地址展示 ───
|
||||
*
|
||||
* 锁的是一次生产事故:单封邮件视图的「发件」行显示成
|
||||
* jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
|
||||
* 而人指定的收件方是 pi@/home/program/agentmail 的那条会话。三处错叠在一起:
|
||||
*
|
||||
* 1. 会话别名被拼给了**发件人** —— 别名是会话的属性、两端共有,不属于任何一方
|
||||
* 2. from_workspace 为空(人类没有工作目录)时拼出 `jianf.<别名>`,
|
||||
* 按三维寻址「最后一个 . 切分」规则,那是 path 位为空的**非法地址**
|
||||
* 3. 抄送显示 `pi@/home/program/agentmail.new` —— `.new` 建完会话就失效了,
|
||||
* 留着会让人以为再发一次还能投进同一条会话
|
||||
*/
|
||||
describe('formatAddress 边界', () => {
|
||||
it('path 为空时保留 @,不能拼成 name.session', () => {
|
||||
// bug 现场:jianf 没有工作目录,界面拼出了 `jianf.某会话别名`。
|
||||
// 那会被 ParseAddress 整串当成名字(实测 name="jianf.某会话别名")——
|
||||
// 一个不存在的 Agent。正确形式是 `jianf@.某会话别名`。
|
||||
expect(formatAddress('jianf', '', '某会话别名')).toBe('jianf@.某会话别名');
|
||||
expect(formatAddress('jianf', '', '某会话别名')).not.toBe('jianf.某会话别名');
|
||||
});
|
||||
|
||||
it('无 session 时 path 为空返回裸名字(不留孤零零的 @)', () => {
|
||||
expect(formatAddress('jianf', '')).toBe('jianf');
|
||||
expect(formatAddress('jianf', ' ')).toBe('jianf');
|
||||
});
|
||||
|
||||
it('name 为空返回空串而不是残缺地址', () => {
|
||||
expect(formatAddress('', '/home')).toBe('');
|
||||
expect(formatAddress(' ', '/home', 'x')).toBe('');
|
||||
});
|
||||
|
||||
it('三段齐全时正常拼接', () => {
|
||||
expect(formatAddress('pi', '/home/program/agentmail', 'my-task'))
|
||||
.toBe('pi@/home/program/agentmail.my-task');
|
||||
});
|
||||
|
||||
it('去首尾空白', () => {
|
||||
expect(formatAddress(' pi ', ' /home ', ' t ')).toBe('pi@/home.t');
|
||||
});
|
||||
|
||||
it('path 含 . 与 / 时仍按最后一个 . 切分(与后端同构)', () => {
|
||||
// 拼出来的地址必须能被后端 ParseAddress 还原
|
||||
const addr = formatAddress('pi', '/home/a.b/c', 'sess');
|
||||
expect(addr).toBe('pi@/home/a.b/c.sess');
|
||||
expect(addr.slice(addr.lastIndexOf('.') + 1)).toBe('sess');
|
||||
});
|
||||
});
|
||||
|
||||
describe('participantAddress', () => {
|
||||
/**
|
||||
* 人与 Agent 的地址维度不同:
|
||||
* Agent 要三段(name@path.session)才唯一确定「哪个 Agent、哪个目录、哪条线索」
|
||||
* 人只要名字(没有工作目录,也不需要指定会话)
|
||||
*/
|
||||
it('Agent 带完整三段 —— 少任何一段都不是可投递地址', () => {
|
||||
expect(participantAddress('pi', false, '/home/program/agentmail', '我的任务'))
|
||||
.toBe('pi@/home/program/agentmail.我的任务');
|
||||
});
|
||||
|
||||
it('Agent 无会话别名时退到 name@path(默认会话)', () => {
|
||||
expect(participantAddress('pi', false, '/home/program/agentmail', null))
|
||||
.toBe('pi@/home/program/agentmail');
|
||||
expect(participantAddress('pi', false, '/home/program/agentmail'))
|
||||
.toBe('pi@/home/program/agentmail');
|
||||
});
|
||||
|
||||
it('人只显示名字,即便传了会话别名也不拼', () => {
|
||||
// 给人拼 `jianf@.某会话` 是把 Agent 的维度硬套在人身上
|
||||
expect(participantAddress('jianf', true, '', '某会话')).toBe('jianf');
|
||||
expect(participantAddress('jianf', true, null, '某会话')).toBe('jianf');
|
||||
expect(participantAddress('jianf', true)).toBe('jianf');
|
||||
});
|
||||
|
||||
it('人的地址里不含 @ 也不含 .', () => {
|
||||
const addr = participantAddress('jianf', true, '', '邮件驱动·多智能体协作平台-完整设计文档');
|
||||
expect(addr).toBe('jianf');
|
||||
expect(addr).not.toContain('@');
|
||||
expect(addr).not.toContain('.');
|
||||
});
|
||||
|
||||
it('人是 Agent 时 workspace 为空也不会拼出裸 @', () => {
|
||||
// 边界:Agent 名在库里但 session_workspace 为空(数据不完整),
|
||||
// 仍然要留 @ 而不是裸名字 —— 否则按最后一个 . 切分会错
|
||||
expect(participantAddress('pi', false, '', 'sess'))
|
||||
.toBe('pi@.sess');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replyAllCC 保留 cc_list 的原始意图', () => {
|
||||
// `.new` 是**原始意图**,不该被替换成主收件人的别名。
|
||||
//
|
||||
// 每个抄送方的 `.new` 是独立的:`pi@/x.new` 给 pi 开一条会话、
|
||||
// `dsh@/x.new` 给 dsh 开另一条,各有自己的别名。把它们统一换成主收件人
|
||||
// 那条会话的别名,等于把三条不同的线索说成同一条 —— 而数据库里 cc_list
|
||||
// 存的就是原文,显示原文没有任何问题。
|
||||
it('抄送里的 .new 原样保留', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home/program/agentmail',
|
||||
cc_list: [{
|
||||
name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new'
|
||||
}]
|
||||
});
|
||||
// 自己是 jianf、主收件人是 pi,剩下 dsh —— `.new` 是原文,不动
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.new']);
|
||||
});
|
||||
|
||||
it('已有具体别名的抄送也原样保留', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'other-task', raw: 'dsh@/opt.other-task' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.other-task']);
|
||||
});
|
||||
|
||||
it('抄送无 raw 时按结构化字段重拼(含会话位)', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'x', raw: '' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.x']);
|
||||
});
|
||||
|
||||
it('人类发件人(无 workspace)在抄送里是裸名字,不带会话位', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_human: true,
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_human: false,
|
||||
to_workspace: '/home',
|
||||
session_alias: '某个很长的会话别名'
|
||||
});
|
||||
// replyAllCC 里的 from/to 走 formatAddress 且**不传 session** ——
|
||||
// 抄送清单是「还要发给谁」,会话由主收件人的地址决定,
|
||||
// 每个抄送方都带一遍会话位是冗余的(且回复时后端按 reply_to 定位会话)
|
||||
expect(replyAllCC(m, 'dsh', 'pi')).toEqual(['jianf']);
|
||||
});
|
||||
});
|
||||
45
client/electron/test/components/setup.ts
Normal file
45
client/electron/test/components/setup.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { afterEach, expect } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||
|
||||
/**
|
||||
* 组件测试的全局准备。
|
||||
*
|
||||
* 三件事,每件都对应一类会串味的状态:
|
||||
* 1. 每个用例后卸载组件树(不卸载的话下一个用例的 getByText 会命中上一个的 DOM)
|
||||
* 2. 重置 zustand store(它是模块级单例,跨用例共享)
|
||||
* 3. 提供 matchMedia —— jsdom 没有实现它,而 useIsNarrow 直接调
|
||||
*/
|
||||
expect.extend(matchers);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* jsdom 不实现 matchMedia。useIsNarrow 会直接调它并挂 change 监听,
|
||||
* 缺了就抛 `matchMedia is not a function`,整个测试文件挂掉。
|
||||
*
|
||||
* 默认返回 false(宽屏)。要测窄屏的用例用 setViewport(true) 覆盖。
|
||||
*/
|
||||
function installMatchMedia(narrow: boolean) {
|
||||
const listeners = new Set<(e: MediaQueryListEvent) => void>();
|
||||
(window as any).matchMedia = (query: string) => ({
|
||||
matches: narrow,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => listeners.add(cb),
|
||||
removeEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb),
|
||||
// 老式 API,某些库仍在用
|
||||
addListener: (cb: (e: MediaQueryListEvent) => void) => listeners.add(cb),
|
||||
removeListener: (cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb),
|
||||
dispatchEvent: () => false
|
||||
});
|
||||
}
|
||||
|
||||
installMatchMedia(false);
|
||||
|
||||
/** 让当前测试文件里的组件按窄屏/宽屏渲染。在 render 之前调。 */
|
||||
export function setViewport(narrow: boolean) {
|
||||
installMatchMedia(narrow);
|
||||
}
|
||||
75
client/electron/test/manual/README.md
Normal file
75
client/electron/test/manual/README.md
Normal file
@ -0,0 +1,75 @@
|
||||
# 手工浏览器实测脚本
|
||||
|
||||
不进 `npm test` —— 它们需要一个跑着的 Chromium 与一个活的 Gateway。
|
||||
日常回归靠 `../narrow-layout.test.mjs`(读源码验形态,无外部依赖)。
|
||||
|
||||
## 为什么两套都要
|
||||
|
||||
结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、点下去命中谁」。
|
||||
|
||||
窄屏那轮修复里最严重的一个 bug 是抽屉式侧栏(`fixed ... z-50` 铺满视口高度)
|
||||
把底部导航最左那一项盖住 —— 按钮在那里、尺寸也够、`md:hidden` 之类的规则也
|
||||
没写错,**只有 `elementFromPoint` 才能发现它命中的是抽屉里的 SVG**。
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
# 窄屏:390px(iPhone 14 Pro)+ 320px(iPhone SE)
|
||||
ADMIN_PW=<密码> npm run test:narrow
|
||||
|
||||
# 宽屏回归:窄屏修复不能把桌面改坏
|
||||
ADMIN_PW=<密码> npm run test:wide
|
||||
```
|
||||
|
||||
环境变量:
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `ADMIN_PW` | 无(必填) | 管理员密码 |
|
||||
| `ADMIN_USER` | `admin` | 登录用户名 |
|
||||
| `AGENTMAIL_URL` | `https://mail.jianfgit.xyz` | 目标地址 |
|
||||
| `CDP_URL` | `http://127.0.0.1:9222` | 浏览器 CDP 端点 |
|
||||
| `PLAYWRIGHT` | `/usr/lib/node_modules/playwright/index.mjs` | playwright 入口 |
|
||||
|
||||
浏览器用的是本机 systemd 托管的共享 Chromium(`homeagent-browser.service`),
|
||||
通过 CDP 连上去开自己的标签页,用完关掉。没有它时先
|
||||
`systemctl start homeagent-browser`。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `narrow-probe-helper.mjs` | 连浏览器、登录、量盒子/溢出/命中区/命中测试 |
|
||||
| `narrow-verify.mjs` | 窄屏 13 项验收 |
|
||||
| `wide-regression.mjs` | 宽屏 5 项回归 |
|
||||
| `inbox-group-verify.mjs` | 收件箱按会话分组 |
|
||||
| `theme-verify.mjs` | 深浅两色的 WCAG 对比度 |
|
||||
| `accent-verify.mjs` | 强调色(红/绿/橙/黄/蓝)17 组配色,两模式各一遍 |
|
||||
|
||||
`narrow-probe-helper.mjs` 里两个函数值得单独知道:
|
||||
|
||||
- `tapTargets(page, labels)` —— 量 `.tap` 按钮的**真实**命中区(`::after`
|
||||
伪元素的尺寸)。`.tap` 刻意不改变视觉尺寸,所以只看 `boundingBox` 会误判成偏小
|
||||
- `hitTest(page, selector)` —— 每个元素点下去是否命中自己。遮挡类 bug 只能这样查
|
||||
|
||||
`accent-verify.mjs` 存在的理由是一次真实事故:`tailwind.config.js` 的 `colors`
|
||||
里同时写了固定 hex 与 `accent()` 两份 red/green/amber/orange/yellow,JS 对象
|
||||
字面量重复键**后者胜出**(不报错),而 `index.css` 当时没有对应的 `--c-red-*`
|
||||
变量。`rgb(var(--c-red-600) / 1)` 里变量未定义 → 整条 `background-color` 声明
|
||||
失效 → `bg-red-600` 退回透明、`text-white` 的白字落在白卡片上:
|
||||
**按钮看不见但点得动**。所有静态检查都过,只有肉眼能发现。
|
||||
|
||||
因此这个脚本量的是**实际计算值**:它把类名注入真页面、读 `getComputedStyle`,
|
||||
把「背景透明」单独判为失败(那正是上述 bug 的指纹),再算 WCAG 对比度。
|
||||
|
||||
只以 `hover:` 变体出现的档(`bg-red-700` / `bg-blue-700`)**不能**放进探针:
|
||||
Tailwind 不生成未被使用的基础类,探它必然得到透明背景 —— 那是假阳性。
|
||||
它们由 `../theme.test.mjs` 的档位断言覆盖。
|
||||
|
||||
## 已知限制
|
||||
|
||||
headless Chromium 报告 `hover: none`,因此 `.reveal`(只在支持悬停的设备上隐藏)
|
||||
在这里永远是可见的 —— 脚本只能验「触摸设备上可见」这一半,
|
||||
「鼠标设备上隐藏」那一半靠 `../narrow-layout.test.mjs` 检查 CSS 规则存在。
|
||||
|
||||
没有像素级视觉比对:字体差异下极脆,维护成本高于收益。
|
||||
84
client/electron/test/manual/accent-verify.mjs
Normal file
84
client/electron/test/manual/accent-verify.mjs
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 强调色可见性验收:真实渲染 + 取实际计算色 + 算 WCAG 对比度。
|
||||
*
|
||||
* 结构性断言(test/theme.test.mjs)只能保证变量存在、档位达标;
|
||||
* 「按钮到底看得见吗」必须在真浏览器里量 —— 上一次那个 bug 正是
|
||||
* 所有静态检查都过、只有肉眼能发现。
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const srgb = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||
const L = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
||||
const ratio = (a, b) => { const l1 = L(a), l2 = L(b); const [h, o] = l1 > l2 ? [l1, l2] : [l2, l1]; return (h + 0.05) / (o + 0.05); };
|
||||
const parse = s => (s.match(/\d+/g) || []).slice(0, 3).map(Number);
|
||||
|
||||
// 把一批强调色类名注入页面,量它们的实际计算值
|
||||
// hover 档(bg-red-700 / bg-blue-700)不在这里:源码里它们只以 `hover:` 变体
|
||||
// 出现,Tailwind 因此不生成基础类 —— 探它会得到透明背景,那是探针的假阳性
|
||||
// 而不是真 bug。它们由 theme.test.mjs 的档位断言覆盖(--s-* 两模式同值)。
|
||||
const PROBES = [
|
||||
// [类名组合, 说明, 期望最低对比度]
|
||||
['bg-red-600 text-white', '危险实心按钮(确认归档 / 删除)', 3.0],
|
||||
['bg-red-500 text-white', '未读徽标', 3.0],
|
||||
['bg-green-600 text-white', '同意按钮', 3.0],
|
||||
['bg-orange-700 text-white', '待决策徽标', 4.5],
|
||||
['bg-blue-600 text-white', '主按钮', 4.5],
|
||||
['bg-red-50 text-red-600', '错误提示条', 4.5],
|
||||
['bg-red-50 text-red-700', '危险区块文字', 4.5],
|
||||
['bg-green-50 text-green-700', '成功提示条', 4.5],
|
||||
['bg-orange-100 text-orange-700', '橙色 chip', 4.5],
|
||||
['bg-amber-100 text-amber-700', '警告 chip', 4.5],
|
||||
['bg-amber-50 text-amber-800', '警告条', 4.5],
|
||||
['bg-yellow-100 text-yellow-700', '黄色 chip', 4.5],
|
||||
['bg-blue-50 text-blue-700', '信息条', 4.5],
|
||||
['bg-red-100 text-red-700', '红色 chip', 4.5],
|
||||
['bg-orange-200 text-orange-800', '深橙 chip', 4.5],
|
||||
['bg-white text-gray-900', '卡片正文', 4.5],
|
||||
['bg-white text-gray-500', '卡片次要文字', 4.5],
|
||||
];
|
||||
|
||||
const { browser, page } = await openApp(WIDE);
|
||||
let fail = 0;
|
||||
|
||||
for (const mode of ['light', 'dark']) {
|
||||
await page.evaluate(m => {
|
||||
document.documentElement.classList.toggle('dark', m === 'dark');
|
||||
let host = document.getElementById('__probe');
|
||||
if (host) host.remove();
|
||||
host = document.createElement('div');
|
||||
host.id = '__probe';
|
||||
host.style.position = 'fixed';
|
||||
host.style.top = '0';
|
||||
host.style.left = '0';
|
||||
host.style.zIndex = '99999';
|
||||
document.body.appendChild(host);
|
||||
}, mode);
|
||||
|
||||
console.log(`\n─── ${mode === 'dark' ? '深色' : '浅色'} ───`);
|
||||
for (const [cls, label, min] of PROBES) {
|
||||
const got = await page.evaluate(c => {
|
||||
const host = document.getElementById('__probe');
|
||||
host.innerHTML = `<span id="__p" class="${c}">测试</span>`;
|
||||
const el = document.getElementById('__p');
|
||||
const s = getComputedStyle(el);
|
||||
return { bg: s.backgroundColor, fg: s.color };
|
||||
}, cls);
|
||||
|
||||
// 透明背景 = 声明失效(正是上次那个 bug 的指纹)
|
||||
const transparent = /rgba\(0,\s*0,\s*0,\s*0\)|transparent/.test(got.bg);
|
||||
if (transparent) {
|
||||
console.log(` 失败 ${label} — 背景透明(${cls} 的 background-color 声明失效)`);
|
||||
fail++;
|
||||
continue;
|
||||
}
|
||||
const r = ratio(parse(got.fg), parse(got.bg));
|
||||
const ok = r >= min;
|
||||
if (!ok) fail++;
|
||||
console.log(` ${ok ? '通过' : '失败'} ${label.padEnd(22)} ${r.toFixed(2)}:1 (需 ${min}) ${got.bg} / ${got.fg}`);
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate(() => document.getElementById('__probe')?.remove());
|
||||
await browser.close();
|
||||
console.log(fail ? `\n!! ${fail} 项不达标` : '\n全部达标');
|
||||
process.exit(fail ? 1 : 0);
|
||||
98
client/electron/test/manual/addr-verify.mjs
Normal file
98
client/electron/test/manual/addr-verify.mjs
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 邮件详情页地址行的真实渲染验收。
|
||||
*
|
||||
* 锁的是那次事故:发件行显示成 `jianf.<会话别名>` —— 别名拼给了发件人,
|
||||
* 而且 path 为空时拼出了 ParseAddress 会整串当成名字的非法形态。
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page } = await openApp(WIDE);
|
||||
let fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) console.log(` 通过 ${name}`);
|
||||
else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); }
|
||||
};
|
||||
|
||||
// 走**发件**箱:报这个 bug 的那封信是人发出去的(jianf → pi,抄送 pi@….new),
|
||||
// 收件箱里只有 Agent 的回信 —— 而回信没有抄送、也不带 `.new`,
|
||||
// 探不到要验的那三处。
|
||||
await page.waitForSelector('button', { timeout: 20000 });
|
||||
await page.click('button:has-text("发件")');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 收件箱/发件箱按会话分组:先点组头展开,再点里面的邮件行。
|
||||
// 两者 className 都含 `w-full text-left`,靠「点完有没有出现含『发件』的 dl」区分。
|
||||
let opened = false;
|
||||
for (let round = 0; round < 3 && !opened; round++) {
|
||||
const btns = await page.$$('button.w-full.text-left');
|
||||
for (const b of btns) {
|
||||
const t = await b.innerText().catch(() => '');
|
||||
if (!t) continue;
|
||||
await b.click().catch(() => {});
|
||||
await page.waitForTimeout(350);
|
||||
const hasMeta = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('dl')].some(dl => dl.textContent.includes('发件')));
|
||||
if (hasMeta) { opened = true; break; }
|
||||
}
|
||||
}
|
||||
check('打开了一封邮件', opened);
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 读元信息 dl
|
||||
const meta = await page.evaluate(() => {
|
||||
const dls = [...document.querySelectorAll('dl')];
|
||||
for (const dl of dls) {
|
||||
const pairs = [];
|
||||
const kids = [...dl.children];
|
||||
for (const div of kids) {
|
||||
const dt = div.querySelector('dt'), dd = div.querySelector('dd');
|
||||
if (dt && dd) pairs.push([dt.textContent.trim(), dd.textContent.trim()]);
|
||||
}
|
||||
if (pairs.some(([k]) => k === '发件')) return Object.fromEntries(pairs);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!meta) {
|
||||
check('找到元信息区', false, '没有含「发件」的 dl');
|
||||
} else {
|
||||
console.log(' 元信息:', JSON.stringify(meta));
|
||||
const from = meta['发件'] || '';
|
||||
const to = meta['收件'] || '';
|
||||
const cc = meta['抄送'] || '';
|
||||
|
||||
// 判「哪一方是人」:人的地址里既无 @ 也无 .
|
||||
const isHuman = a => a && !a.includes('@') && !a.includes('.');
|
||||
const human = isHuman(from) ? from : (isHuman(to) ? to : '');
|
||||
const agent = isHuman(from) ? to : from;
|
||||
|
||||
check('有一方是人(裸名字,无 @ 无 .)', !!human, `发件=${from} 收件=${to}`);
|
||||
check('人的地址不含会话位', !human || !/[@.]/.test(human), `人=${human}`);
|
||||
|
||||
// Agent 必须三段齐全:name@path.session
|
||||
const threeSeg = /^[^@]+@\/[^@]*\.[^.@]+$/.test(agent);
|
||||
check('Agent 带完整三段 name@path.session', threeSeg, `agent=${agent}`);
|
||||
|
||||
// path 不能是 Agent 名(`dsh@dsh` 那个 bug)
|
||||
if (agent.includes('@')) {
|
||||
const [n, rest] = [agent.slice(0, agent.indexOf('@')), agent.slice(agent.indexOf('@') + 1)];
|
||||
check('Agent 的 path 是真路径而不是 Agent 名',
|
||||
rest.startsWith('/'), `${n}@${rest}`);
|
||||
}
|
||||
|
||||
// 别名不能挂在人身上
|
||||
check('会话别名跟着 Agent 而不是人',
|
||||
!human || !agent || agent.includes('.'), `人=${human} agent=${agent}`);
|
||||
|
||||
// 抄送里不能残留 .new
|
||||
check('抄送里没有 .new',
|
||||
!cc.split('、').some(a => a.trim().endsWith('.new')), `抄送=${cc}`);
|
||||
if (cc) console.log(` 抄送实际值: ${cc}`);
|
||||
|
||||
// 不该再有独立的「会话」行(别名已在 Agent 地址里)
|
||||
check('没有多余的「会话」行', !('会话' in meta), `会话=${meta['会话']}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(fail ? `\n!! ${fail} 项不达标` : '\n全部达标');
|
||||
process.exit(fail ? 1 : 0);
|
||||
168
client/electron/test/manual/inbox-group-verify.mjs
Normal file
168
client/electron/test/manual/inbox-group-verify.mjs
Normal file
@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 收件箱分组 + 授权独立列表的实测验收。
|
||||
*
|
||||
* 起因:生产库里一个会话独占 17 封权限邮件,把另外两个会话的信挤出视野
|
||||
* —— 收件箱失去了它唯一的作用(让人知道有哪几件事在等我)。
|
||||
*
|
||||
* 修法两层,这里各验一层:
|
||||
* 1. 权限请求整体移出收件箱,进「授权」导航项(一级会话 / 二级请求)
|
||||
* 2. 收件箱剩下的普通邮件按会话折叠
|
||||
*
|
||||
* 单元测试(test/components/mailGroups.test.tsx)守住分组函数的算术,
|
||||
* 量不出「组头真的只有一行」「折叠时组内邮件确实不在 DOM 里」这些
|
||||
* 只有真实渲染才能验的事。
|
||||
*
|
||||
* 用法:ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||
* node client/electron/test/manual/inbox-group-verify.mjs
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
const failed = [];
|
||||
|
||||
async function check(name, fn) {
|
||||
try {
|
||||
const r = await fn();
|
||||
console.log(` ${r.ok ? '通过' : '失败'} ${name}${r.note ? ' — ' + r.note : ''}`);
|
||||
if (!r.ok) failed.push(name);
|
||||
} catch (e) {
|
||||
console.log(` 错误 ${name} — ${e.message.slice(0, 100)}`);
|
||||
failed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
/** 中间栏里可点的条目数(组头 + 展开出来的行)。 */
|
||||
const rowCount = () => page.locator('div.overflow-y-auto button').count();
|
||||
|
||||
async function goto(label) {
|
||||
await page.click(`aside button[title*="${label}"], button[title*="${label}"]`);
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
// ───────────────── 收件箱:权限已移出、其余按会话折叠 ─────────────────
|
||||
console.log('\n收件箱:');
|
||||
|
||||
await goto('收件箱');
|
||||
|
||||
await check('权限请求不再出现在收件箱', async () => {
|
||||
const txt = await page.locator('div.overflow-y-auto').innerText();
|
||||
// 「待决策」「等待你决策」都是授权列表的措辞;收件箱里不该有
|
||||
const leaked = /待决策|等待你决策/.test(txt);
|
||||
return { ok: !leaked, note: leaked ? '收件箱里出现了权限措辞' : '干净' };
|
||||
});
|
||||
|
||||
await check('多封邮件的会话折叠成一行', async () => {
|
||||
const header = await page.locator('h2:has-text("收件箱")').locator('..').innerText();
|
||||
const m = header.match(/(\d+)\s*组\s*·\s*(\d+)\s*封/);
|
||||
if (!m) {
|
||||
// 每个会话都只有一封时不显示「N 组 · M 封」,这是设计(单封平铺)
|
||||
return { ok: true, note: `无可折叠会话:${header.replace(/\n/g, ' ').trim()}` };
|
||||
}
|
||||
return { ok: Number(m[1]) < Number(m[2]), note: `${m[1]} 组 / ${m[2]} 封` };
|
||||
});
|
||||
|
||||
// ───────────────── 授权:一级会话 / 二级请求 ─────────────────
|
||||
console.log('\n授权列表:');
|
||||
|
||||
await goto('授权');
|
||||
|
||||
await check('侧栏有独立的「授权」入口', async () => {
|
||||
const n = await page.locator('button[title*="授权"]').count();
|
||||
return { ok: n > 0, note: `${n} 个入口` };
|
||||
});
|
||||
|
||||
await check('权限请求按会话分组(一级是会话)', async () => {
|
||||
const heads = await page.locator('div.overflow-y-auto > div > button').count();
|
||||
const txt = await page.locator('div.overflow-y-auto').innerText();
|
||||
if (heads === 0 && /没有授权请求/.test(txt)) {
|
||||
return { ok: true, note: '当前无授权请求(空态正常)' };
|
||||
}
|
||||
// 组头带会话别名(.alias 或「(未命名会话)」)
|
||||
const hasAlias = /\.\S+|\(未命名会话\)/.test(txt);
|
||||
return { ok: heads > 0 && hasAlias, note: `${heads} 个会话组头` };
|
||||
});
|
||||
|
||||
await check('待决策的会话默认展开(在等人的不能藏)', async () => {
|
||||
const txt = await page.locator('div.overflow-y-auto').innerText();
|
||||
if (/没有授权请求/.test(txt)) return { ok: true, note: '无授权请求,跳过' };
|
||||
const pendingBadge = await page.locator('span:has-text("待决策")').count();
|
||||
if (pendingBadge === 0) {
|
||||
return { ok: /已全部处理/.test(txt), note: '全部已处理,组头显示「已全部处理」' };
|
||||
}
|
||||
// 有待决策 → 组内应已展开,能看到「等待你决策」的行
|
||||
const rows = await page.locator('span:has-text("等待你决策")').count();
|
||||
return { ok: rows > 0, note: `${pendingBadge} 个待决策徽标,${rows} 行展开可见` };
|
||||
});
|
||||
|
||||
await check('已决策的历史折进二级,不默认铺开', async () => {
|
||||
const toggle = page.locator('button:has-text("已决策")');
|
||||
const n = await toggle.count();
|
||||
if (n === 0) return { ok: true, note: '无已决策历史,跳过' };
|
||||
const label = await toggle.first().innerText();
|
||||
return { ok: label.includes('展开'), note: label.trim() };
|
||||
});
|
||||
|
||||
await check('二级折叠可展开', async () => {
|
||||
const toggle = page.locator('button:has-text("已决策")');
|
||||
if ((await toggle.count()) === 0) return { ok: true, note: '无二级折叠,跳过' };
|
||||
const before = await rowCount();
|
||||
await toggle.first().click();
|
||||
await page.waitForTimeout(600);
|
||||
const after = await rowCount();
|
||||
return { ok: after > before, note: `${before} → ${after} 个条目` };
|
||||
});
|
||||
|
||||
await check('点一条授权请求能打开决策面板', async () => {
|
||||
// 授权行带「等待你决策」或决策结果;组头带「待决策 N」或「已全部处理」
|
||||
const row = page
|
||||
.locator('div.overflow-y-auto button')
|
||||
.filter({ hasText: /等待你决策|同意|拒绝/ })
|
||||
.filter({ hasNotText: /待决策 \d|已全部处理|展开已决策|收起已决策/ });
|
||||
if ((await row.count()) === 0) return { ok: true, note: '无授权请求,跳过' };
|
||||
await row.first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
// 右栏出现权限决策按钮或已决策的结果说明
|
||||
const panel =
|
||||
(await page.locator('button:has-text("同意")').count()) > 0 ||
|
||||
(await page.locator('text=/已(同意|拒绝|决策)/').count()) > 0;
|
||||
return { ok: panel, note: panel ? '决策面板已渲染' : '右栏没有内容' };
|
||||
});
|
||||
|
||||
await check('组头可收起', async () => {
|
||||
const head = page.locator('div.overflow-y-auto > div > button').first();
|
||||
if ((await head.count()) === 0) return { ok: true, note: '无组头,跳过' };
|
||||
const before = await rowCount();
|
||||
await head.click();
|
||||
await page.waitForTimeout(600);
|
||||
const after = await rowCount();
|
||||
// 原本折叠的组点一下会展开;原本展开的会收起。两种都算「可切换」
|
||||
return { ok: after !== before, note: `${before} → ${after}` };
|
||||
});
|
||||
|
||||
// ───────────────── 徽标语义 ─────────────────
|
||||
console.log('\n徽标:');
|
||||
|
||||
await check('收件箱未读数不把权限请求算进来', async () => {
|
||||
const badge = page.locator('button[title*="收件箱"] span').filter({ hasText: /^\d+$/ });
|
||||
const inboxBadge = (await badge.count()) > 0 ? await badge.first().innerText() : '0';
|
||||
const permBadge = page.locator('button[title*="授权"] span').filter({ hasText: /^\d+$/ });
|
||||
const pBadge = (await permBadge.count()) > 0 ? await permBadge.first().innerText() : '0';
|
||||
// 两个数字各自独立;一个待批的 bash 不该在两处都计数
|
||||
return { ok: true, note: `收件箱未读 ${inboxBadge},授权待决策 ${pBadge}` };
|
||||
});
|
||||
|
||||
await check('无 JS 运行时错误', async () => {
|
||||
// 404 资源(favicon 之类)不算 JS 错误
|
||||
const real = issues.filter(i => !/404|Failed to load resource/.test(i));
|
||||
return { ok: real.length === 0, note: real.length ? real.slice(0, 2).join(' | ') : '无' };
|
||||
});
|
||||
|
||||
console.log(
|
||||
failed.length === 0
|
||||
? '\n收件箱分组 + 授权列表:全部通过'
|
||||
: `\n收件箱分组 + 授权列表:${failed.length} 项失败 — ${failed.join('、')}`
|
||||
);
|
||||
|
||||
await page.close();
|
||||
await browser.close();
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
262
client/electron/test/manual/narrow-probe-helper.mjs
Normal file
262
client/electron/test/manual/narrow-probe-helper.mjs
Normal file
@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 窄屏实测辅助:连本机共享 Chromium(CDP 127.0.0.1:9222)量真实盒子。
|
||||
*
|
||||
* 这是**手工脚本**,不进 `npm test` —— 它需要一个跑着的浏览器与一个活的
|
||||
* Gateway。日常回归靠 `../narrow-layout.test.mjs` 的结构性断言。
|
||||
*
|
||||
* 两者分工:结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、
|
||||
* 点下去命中谁」。抽屉遮挡底部导航那个 bug(`elementFromPoint` 命中抽屉里的
|
||||
* SVG 而不是导航按钮)只有这样才能发现。
|
||||
*
|
||||
* 用法:
|
||||
* ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
|
||||
* ADMIN_PW=<密码> node client/electron/test/manual/wide-regression.mjs
|
||||
*
|
||||
* 环境变量:
|
||||
* ADMIN_PW 必填,管理员密码
|
||||
* AGENTMAIL_URL 目标地址,默认 https://mail.jianfgit.xyz
|
||||
* CDP_URL 浏览器 CDP 端点,默认 http://127.0.0.1:9222
|
||||
* PLAYWRIGHT playwright 入口,默认 /usr/lib/node_modules/playwright/index.mjs
|
||||
*/
|
||||
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
|
||||
const { chromium } = await import(PLAYWRIGHT);
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const { extname, join } = await import('node:path');
|
||||
|
||||
const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
|
||||
const APP = (process.env.AGENTMAIL_URL || 'https://mail.jianfgit.xyz').replace(/\/$/, '');
|
||||
const DIST = process.env.AGENTMAIL_DIST?.replace(/\/$/, '');
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.woff2': 'font/woff2'
|
||||
};
|
||||
|
||||
export const PHONE = { width: 390, height: 844 }; // iPhone 14 Pro
|
||||
export const SMALL = { width: 320, height: 568 }; // iPhone SE 1 代
|
||||
export const WIDE = { width: 1280, height: 800 };
|
||||
|
||||
export async function openApp(viewport = PHONE) {
|
||||
const browser = await chromium.connectOverCDP(CDP);
|
||||
const ctx = browser.contexts()[0] ?? (await browser.newContext());
|
||||
const page = await ctx.newPage();
|
||||
await page.setViewportSize(viewport);
|
||||
|
||||
const issues = [];
|
||||
page.on('pageerror', e => issues.push('pageerror: ' + String(e).slice(0, 220)));
|
||||
page.on('console', m => {
|
||||
if (m.type() === 'error') {
|
||||
const t = m.text();
|
||||
// 401 是未登录时的正常探测,不算问题
|
||||
if (!t.includes('401')) issues.push('console: ' + t.slice(0, 200));
|
||||
}
|
||||
});
|
||||
|
||||
// 在不替换、不重启现有 Gateway 的前提下验证本次构建:仅把页面壳和静态资源
|
||||
// 从 dist 注入当前标签页,API/SSE 仍由 APP 指向的真实 Gateway 提供。
|
||||
if (DIST) {
|
||||
const appOrigin = new URL(APP).origin;
|
||||
await page.route(`${appOrigin}/**`, async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
const relative = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '');
|
||||
const file = join(DIST, relative);
|
||||
if (!file.startsWith(DIST + '/') && file !== join(DIST, 'index.html')) {
|
||||
await route.abort('blockedbyclient');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readFile(file);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body,
|
||||
contentType: contentTypes[extname(file)] || 'application/octet-stream'
|
||||
});
|
||||
} catch {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 不能用 networkidle:SSE 是一条永不结束的长连接,networkidle 永远不触发
|
||||
await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// 需要登录时登录(同一 context 共享 cookie,通常只登一次)
|
||||
if ((await page.locator('input[autocomplete=username]').count()) > 0) {
|
||||
if (!process.env.ADMIN_PW) throw new Error('需要 ADMIN_PW 环境变量');
|
||||
await page.fill('input[autocomplete=username]', process.env.ADMIN_USER || 'admin');
|
||||
await page.fill('input[type=password]', process.env.ADMIN_PW);
|
||||
await page.click('button:has-text("登录")');
|
||||
await page.waitForTimeout(3500);
|
||||
}
|
||||
return { browser, page, issues };
|
||||
}
|
||||
|
||||
/** 量一个元素的盒子;不存在返回 null。 */
|
||||
export async function box(page, sel) {
|
||||
const el = page.locator(sel).first();
|
||||
if ((await el.count()) === 0) return null;
|
||||
return await el.boundingBox();
|
||||
}
|
||||
|
||||
/**
|
||||
* 有没有横向溢出 —— 窄屏最常见的毛病。
|
||||
*
|
||||
* 只报 `right` 超过文档宽度的元素:溢出到左边通常是有意的负 margin。
|
||||
*/
|
||||
export async function overflowX(page) {
|
||||
return await page.evaluate(() => {
|
||||
const de = document.documentElement;
|
||||
const over = [];
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width > 0 && r.right > de.clientWidth + 1) {
|
||||
over.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
cls: (el.className || '').toString().slice(0, 70),
|
||||
right: Math.round(r.right),
|
||||
text: (el.textContent || '').trim().slice(0, 40)
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
docWidth: de.clientWidth,
|
||||
scrollWidth: de.scrollWidth,
|
||||
bodyScrollWidth: document.body.scrollWidth,
|
||||
offenders: over.slice(0, 8)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击目标够不够大。
|
||||
*
|
||||
* 量的是**视觉尺寸**;有 `.tap` 的按钮视觉上仍然很小,命中区在
|
||||
* `::after` 伪元素上 —— 用 tapTargets() 才能看到真实命中区。
|
||||
*/
|
||||
export async function smallTargets(page, min = 40) {
|
||||
return await page.evaluate(min => {
|
||||
const bad = [];
|
||||
for (const el of document.querySelectorAll(
|
||||
'button, a, [role=button], input[type=checkbox]'
|
||||
)) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) continue; // 隐藏的不算
|
||||
if (r.height < min || r.width < min) {
|
||||
bad.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
w: Math.round(r.width),
|
||||
h: Math.round(r.height),
|
||||
text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 28)
|
||||
});
|
||||
}
|
||||
}
|
||||
return bad;
|
||||
}, min);
|
||||
}
|
||||
|
||||
/**
|
||||
* 量 `.tap` 按钮的真实命中区(`::after` 伪元素的尺寸)。
|
||||
*
|
||||
* @param labels 只看这些文字的按钮
|
||||
*/
|
||||
export async function tapTargets(page, labels) {
|
||||
return await page.evaluate(labels => {
|
||||
const out = [];
|
||||
for (const b of document.querySelectorAll('button')) {
|
||||
const t = (b.textContent || '').trim();
|
||||
if (labels.length && !labels.includes(t)) continue;
|
||||
const bb = b.getBoundingClientRect();
|
||||
if (bb.width === 0) continue;
|
||||
const cs = getComputedStyle(b, '::after');
|
||||
out.push({
|
||||
t,
|
||||
visual: `${Math.round(bb.width)}x${Math.round(bb.height)}`,
|
||||
hitW: Math.round(parseFloat(cs.width) || 0),
|
||||
hitH: Math.round(parseFloat(cs.height) || 0)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, labels);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个元素点下去是否命中自己。
|
||||
*
|
||||
* 这是抽屉遮挡 bug 的检测手段:按钮明明在那里、尺寸也够,
|
||||
* 但上面盖了一层 `fixed z-50`,`elementFromPoint` 命中的是别人。
|
||||
*/
|
||||
export async function hitTest(page, selector) {
|
||||
return await page.evaluate(selector => {
|
||||
const out = [];
|
||||
for (const el of document.querySelectorAll(selector)) {
|
||||
const bb = el.getBoundingClientRect();
|
||||
if (bb.width === 0) continue;
|
||||
const top = document.elementFromPoint(
|
||||
Math.round(bb.x + bb.width / 2),
|
||||
Math.round(bb.y + bb.height / 2)
|
||||
);
|
||||
out.push({
|
||||
text: (el.textContent || '').replace(/\s+/g, '').slice(0, 8),
|
||||
hit: el.contains(top) || el === top
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个页面是否**有**纵向滚动容器。
|
||||
*
|
||||
* 判据是「存在 overflow-y:auto|scroll 的容器」,不是「当前正在滚动」——
|
||||
* 内容暂时不够高时后者为假,但页面是健康的。真正的 bug 是**根本没有**滚动
|
||||
* 容器:内容一旦超过视口就被 `overflow-hidden` 的父级裁掉,没有任何办法看到。
|
||||
*
|
||||
* 「我的」页就是这样坏的:内容(资料+权限+改密码+密钥+退出)在 390px 下需要
|
||||
* 860px,容器只有 795px,超出那 65px 连同「退出登录」按钮一起消失。
|
||||
*
|
||||
* @returns {{ hasScroller: boolean, scrollers: object[], clipped: object[] }}
|
||||
*/
|
||||
export async function scrollHealth(page) {
|
||||
return await page.evaluate(() => {
|
||||
const root = document.querySelector('#root');
|
||||
const scrollers = [];
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') {
|
||||
scrollers.push({
|
||||
cls: (el.className || '').toString().slice(0, 45),
|
||||
scrollH: el.scrollHeight,
|
||||
clientH: el.clientHeight,
|
||||
needsScroll: el.scrollHeight > el.clientHeight + 4
|
||||
});
|
||||
}
|
||||
}
|
||||
// 内容超出但被 overflow:hidden 的父级裁掉 —— 这才是真正的问题
|
||||
const clipped = [];
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (el.scrollHeight > el.clientHeight + 20 && cs.overflowY === 'visible') {
|
||||
const p = el.parentElement;
|
||||
const pcs = p ? getComputedStyle(p) : null;
|
||||
if (pcs && (pcs.overflow === 'hidden' || pcs.overflowY === 'hidden')) {
|
||||
clipped.push({
|
||||
cls: (el.className || '').toString().slice(0, 50),
|
||||
have: el.clientHeight,
|
||||
need: el.scrollHeight
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { hasScroller: scrollers.length > 0, scrollers, clipped: clipped.slice(0, 4) };
|
||||
});
|
||||
}
|
||||
174
client/electron/test/manual/narrow-verify.mjs
Normal file
174
client/electron/test/manual/narrow-verify.mjs
Normal file
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 窄屏实测验收:390px 与 320px 下量真实盒子、真实命中。
|
||||
*
|
||||
* 每一条都对应一个曾经真实存在的问题(见 docs/PLAN.md §7.10.1):
|
||||
* 抽屉盖住底部导航、工具按钮只有 16px 高、看不见却按得动的「归档」、
|
||||
* 对话树缩进把卡片压成竖条、对话树没有返回出口。
|
||||
*
|
||||
* 用法:ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
|
||||
*/
|
||||
import {
|
||||
openApp,
|
||||
overflowX,
|
||||
tapTargets,
|
||||
hitTest,
|
||||
scrollHealth,
|
||||
SMALL
|
||||
} from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page, issues } = await openApp();
|
||||
const failed = [];
|
||||
|
||||
async function check(name, fn) {
|
||||
try {
|
||||
const r = await fn();
|
||||
console.log(` ${r.ok ? '通过' : '失败'} ${name}${r.note ? ' — ' + r.note : ''}`);
|
||||
if (!r.ok) failed.push(name);
|
||||
} catch (e) {
|
||||
console.log(` 错误 ${name} — ${e.message.slice(0, 90)}`);
|
||||
failed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开收件箱里的第一封邮件。
|
||||
*
|
||||
* 邮件行是 `<button class="w-full text-left ...">`,不是带 cursor-pointer 的 div
|
||||
* —— 按后者找会一直等到超时。
|
||||
*/
|
||||
async function openFirstMail(page) {
|
||||
await page.click('nav button:has-text("收件")');
|
||||
await page.waitForTimeout(1000);
|
||||
const rows = page.locator('button.w-full.text-left');
|
||||
const n = await rows.count();
|
||||
if (n === 0) throw new Error('收件箱是空的,没有邮件可点');
|
||||
await rows.first().click();
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
console.log('窄屏实测(390px):');
|
||||
|
||||
// 抽屉已删。它是 fixed z-50 铺满视口高度,把底部导航最左那项盖住点不到。
|
||||
await check('抽屉入口已移除', async () => {
|
||||
const n = await page.locator('button[aria-label="打开导航"]').count();
|
||||
return { ok: n === 0, note: n ? `仍有 ${n} 个` : '' };
|
||||
});
|
||||
|
||||
// 删抽屉时退出登录是它唯一的独有入口,必须有新去处
|
||||
await check('「我的」页有退出登录', async () => {
|
||||
await page.click('nav button:has-text("我的")');
|
||||
await page.waitForTimeout(1200);
|
||||
const btn = page.locator('button:has-text("退出登录")');
|
||||
const n = await btn.count();
|
||||
const b = n ? await btn.first().boundingBox() : null;
|
||||
return {
|
||||
ok: n === 1 && b.height >= 36,
|
||||
note: b ? `${Math.round(b.width)}x${Math.round(b.height)}` : '找不到'
|
||||
};
|
||||
});
|
||||
|
||||
// 核心回归:底部导航每一项都要命中自己
|
||||
await check('底部导航每项都命中自己', async () => {
|
||||
const r = await hitTest(page, 'nav button');
|
||||
const miss = r.filter(x => !x.hit);
|
||||
return {
|
||||
ok: r.length > 0 && miss.length === 0,
|
||||
note: miss.length ? '未命中: ' + miss.map(m => m.text).join(',') : `${r.length} 项全部命中`
|
||||
};
|
||||
});
|
||||
|
||||
// 详情页工具按钮:视觉 15-16px,命中区必须补到 44
|
||||
await check('详情页工具按钮命中区 >= 44px', async () => {
|
||||
await openFirstMail(page);
|
||||
|
||||
const r = await tapTargets(page, ['标记已读', '对话树', '转发', '抄送', '发送', '清空']);
|
||||
for (const x of r) console.log(' ', JSON.stringify(x));
|
||||
const small = r.filter(x => x.hitH < 44 || x.hitW < 44);
|
||||
return {
|
||||
ok: r.length > 0 && small.length === 0,
|
||||
note: small.length ? '仍偏小: ' + small.map(s => s.t).join(',') : `${r.length} 个都达标`
|
||||
};
|
||||
});
|
||||
|
||||
// 次要动作在触摸设备上必须可见(没有 hover 时曾经永远透明却接收点击)
|
||||
await check('联系人页次要动作默认可见', async () => {
|
||||
await page.click('nav button:has-text("联系人")');
|
||||
await page.waitForTimeout(1200);
|
||||
const r = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('.reveal')].slice(0, 3).map(d => getComputedStyle(d).opacity)
|
||||
);
|
||||
return { ok: r.length > 0 && r.every(o => o === '1'), note: `opacity=${r.join(',')}` };
|
||||
});
|
||||
|
||||
// 对话树:窄屏要有返回出口
|
||||
await check('对话树有返回出口', async () => {
|
||||
await openFirstMail(page);
|
||||
const t = page.locator('button:has-text("对话树")');
|
||||
if ((await t.count()) === 0) return { ok: false, note: '找不到对话树入口' };
|
||||
await t.first().click();
|
||||
await page.waitForTimeout(1600);
|
||||
const n = await page.locator('button[aria-label="返回"]').count();
|
||||
return { ok: n >= 1, note: `${n} 个` };
|
||||
});
|
||||
|
||||
// 各页无横向溢出
|
||||
for (const label of ['收件', '联系人', '管理', '我的']) {
|
||||
await check(`${label}页无横向溢出`, async () => {
|
||||
await page.click(`nav button:has-text("${label}")`);
|
||||
await page.waitForTimeout(1100);
|
||||
const of = await overflowX(page);
|
||||
for (const o of of.offenders) console.log(' 超出:', JSON.stringify(o));
|
||||
return { ok: of.scrollWidth <= of.docWidth, note: `doc=${of.docWidth} scroll=${of.scrollWidth}` };
|
||||
});
|
||||
}
|
||||
|
||||
// 每个页面都必须有纵向滚动容器 —— 否则内容一超过视口就被裁掉看不到。
|
||||
// 「我的」页曾经缺这个:390px 下内容需 860px、容器 795px,
|
||||
// 「退出登录」按钮连同下面 65px 一起消失,滚也滚不到。
|
||||
for (const label of ['收件', '发件', '联系人', '管理', '我的']) {
|
||||
await check(`${label}页有纵向滚动容器`, async () => {
|
||||
await page.click(`nav button:has-text("${label}")`);
|
||||
await page.waitForTimeout(1200);
|
||||
const h = await scrollHealth(page);
|
||||
for (const c of h.clipped) console.log(' 被裁:', JSON.stringify(c));
|
||||
return {
|
||||
ok: h.hasScroller && h.clipped.length === 0,
|
||||
note: h.hasScroller
|
||||
? `${h.scrollers.length} 个容器${h.clipped.length ? ',但有内容被裁' : ''}`
|
||||
: '没有滚动容器'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n最窄(320px):');
|
||||
await page.setViewportSize(SMALL);
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await check('320px 收件箱无横向溢出', async () => {
|
||||
await page.click('nav button:has-text("收件")');
|
||||
await page.waitForTimeout(1000);
|
||||
const of = await overflowX(page);
|
||||
return { ok: of.scrollWidth <= of.docWidth, note: `doc=${of.docWidth} scroll=${of.scrollWidth}` };
|
||||
});
|
||||
|
||||
await check('320px 模型范围排序按钮命中区达标', async () => {
|
||||
await page.click('nav button:has-text("管理")');
|
||||
await page.waitForTimeout(900);
|
||||
await page.click('button:has-text("模型范围")');
|
||||
await page.waitForTimeout(1000);
|
||||
const first = page.locator('button:has-text("dsh")').first();
|
||||
if ((await first.count()) === 0) return { ok: false, note: '没有 Agent 可展开' };
|
||||
await first.click();
|
||||
await page.waitForTimeout(1600);
|
||||
const r = await tapTargets(page, ['↑', '↓', '×']);
|
||||
if (r.length === 0) return { ok: true, note: '当前没有已选模型,跳过' };
|
||||
const small = r.filter(x => x.hitH < 44 || x.hitW < 44);
|
||||
return { ok: small.length === 0, note: `${r.length} 个,最小 ${Math.min(...r.map(x => x.hitH))}px 高` };
|
||||
});
|
||||
|
||||
console.log('\nissues:', issues.length ? issues : '无');
|
||||
console.log(failed.length === 0 ? '\n窄屏实测:全部通过' : `\n窄屏实测:${failed.length} 项失败 — ${failed.join(', ')}`);
|
||||
|
||||
await page.close();
|
||||
await browser.close();
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
221
client/electron/test/manual/responsive-verify.mjs
Normal file
221
client/electron/test/manual/responsive-verify.mjs
Normal file
@ -0,0 +1,221 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { extname, join } from 'node:path';
|
||||
|
||||
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
|
||||
const { chromium } = await import(PLAYWRIGHT);
|
||||
const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
|
||||
const APP = 'http://127.0.0.1:8180';
|
||||
const DIST = process.env.AGENTMAIL_DIST || '/home/program/agentmail/client/electron/dist';
|
||||
const types = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.woff2': 'font/woff2'
|
||||
};
|
||||
|
||||
const user = {
|
||||
user_id: 'u-test', username: 'tester', display_name: '测试用户', role: 'admin',
|
||||
status: 'active', allowed_agents: [], allowed_paths: [], created_at: '2025-01-01T00:00:00Z'
|
||||
};
|
||||
const mail = {
|
||||
mail_id: 'm-test', session_id: 's-test', parent_mail_id: null,
|
||||
from_name: 'deepseekharness', from_workspace: 'deepseekharness', to_name: 'tester', to_workspace: '',
|
||||
cc_list: [], subject: '响应式布局测试邮件', body: '## 正文\n\n用于浏览器布局验证。', body_preview: '用于浏览器布局验证。',
|
||||
mail_type: 'normal', permission_options: null, permission_result: null, status: 'unread',
|
||||
created_at: '2025-01-02T03:04:00Z', session_alias: 'layout-test', session_workspace: '/program/test',
|
||||
attachments: [], from_human: false, to_human: true
|
||||
};
|
||||
const contact = {
|
||||
session_id: 's-test', agent_name: 'deepseekharness', path: '/program/test', session_alias: 'layout-test',
|
||||
address: 'deepseekharness@/program/test.layout-test', status: 'active', mail_count: 1, unread_count: 1,
|
||||
last_activity: '2025-01-02T03:04:00Z', subject: '响应式布局测试邮件', max_rounds: 0, used_rounds: 0,
|
||||
last_from: 'deepseekharness', last_preview: '用于浏览器布局验证。'
|
||||
};
|
||||
|
||||
function json(route, body, status = 200) {
|
||||
return route.fulfill({ status, contentType: 'application/json; charset=utf-8', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function mockApi(route, url) {
|
||||
const p = url.pathname.replace(/^\/api\/v1/, '');
|
||||
if (p === '/auth/me') return json(route, { user });
|
||||
if (p === '/setup/status') return json(route, { needs_setup: false });
|
||||
if (p === '/me/mail/inbox') return json(route, { mails: [mail], total: 1 });
|
||||
if (p === '/me/mail/sent') return json(route, { mails: [] });
|
||||
if (p === '/me/sessions') return json(route, { sessions: [] });
|
||||
if (p === '/contacts') return json(route, { contacts: [contact] });
|
||||
if (p === '/contacts/suggest') {
|
||||
return json(route, {
|
||||
kind: 'name', suggestions: ['deepseekharness', 'pi', 'opencode'],
|
||||
candidates: [
|
||||
{ alias: 'deepseekharness', source: 'mail', title: '布局测试', unread: 1 },
|
||||
{ alias: 'pi', source: 'platform', title: 'Pi' },
|
||||
{ alias: 'opencode', source: 'platform', title: 'OpenCode' }
|
||||
]
|
||||
});
|
||||
}
|
||||
if (p === '/agents') return json(route, { agents: [] });
|
||||
if (p === '/me/keys') return json(route, { keys: [] });
|
||||
if (p === '/admin/users') return json(route, { users: [user] });
|
||||
if (p === '/admin/scopes') return json(route, { agents: [], paths: [] });
|
||||
if (p === '/admin/quotas') return json(route, { quotas: [] });
|
||||
if (p === '/calendar/events') return json(route, { events: [] });
|
||||
if (p === '/events/stream') {
|
||||
return route.fulfill({ status: 200, contentType: 'text/event-stream', body: 'event: connected\ndata: {}\n\n' });
|
||||
}
|
||||
if (p === '/mail/m-test') return json(route, mail);
|
||||
if (p === '/mail/m-test/read') return json(route, { status: 'ok' });
|
||||
if (p === '/mail/m-test/thread') {
|
||||
return json(route, { anchor_mail_id: 'm-test', root_mail_id: 'm-test', anchor_depth: 0, nodes: [], total: 0, hidden: 0, has_more: false, next_offset: 0 });
|
||||
}
|
||||
return json(route, { error: `unmocked ${p}` }, 404);
|
||||
}
|
||||
|
||||
async function installRoutes(page) {
|
||||
await page.route(`${APP}/**`, async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.startsWith('/api/v1/')) return mockApi(route, url);
|
||||
const rel = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '');
|
||||
const file = join(DIST, rel);
|
||||
try {
|
||||
const body = await readFile(file);
|
||||
return route.fulfill({ status: 200, body, contentType: types[extname(file)] || 'application/octet-stream' });
|
||||
} catch {
|
||||
return route.fulfill({ status: 404, body: 'not found' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const browser = await chromium.connectOverCDP(CDP);
|
||||
const context = browser.contexts()[0];
|
||||
const failures = [];
|
||||
let passed = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
console.log(` ${ok ? '通过' : '失败'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
if (ok) passed++; else failures.push(name);
|
||||
}
|
||||
|
||||
async function open(viewport) {
|
||||
const page = await context.newPage();
|
||||
await page.setViewportSize(viewport);
|
||||
await installRoutes(page);
|
||||
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('text=收件箱');
|
||||
await page.waitForTimeout(250);
|
||||
return page;
|
||||
}
|
||||
|
||||
async function openCompose(page, narrow) {
|
||||
if (narrow) await page.locator('nav button').filter({ hasText: '新建' }).click();
|
||||
else await page.locator('button[title="新建邮件"]').click();
|
||||
await page.waitForSelector('textarea');
|
||||
}
|
||||
|
||||
async function measureCompose(page, name, narrow) {
|
||||
await openCompose(page, narrow);
|
||||
const m = await page.evaluate(() => {
|
||||
const ta = document.querySelector('textarea');
|
||||
const attach = [...document.querySelectorAll('button')].find(b => b.textContent?.includes('添加附件'));
|
||||
const send = [...document.querySelectorAll('button')].find(b => b.textContent?.trim() === '发送');
|
||||
const root = ta?.closest('.overflow-y-auto, .lg\\:overflow-hidden');
|
||||
attach?.scrollIntoView({ block: 'center' });
|
||||
const tr = ta?.getBoundingClientRect();
|
||||
const ar = attach?.getBoundingClientRect();
|
||||
const sr = send?.parentElement?.getBoundingClientRect();
|
||||
return {
|
||||
textareaH: tr?.height || 0,
|
||||
textareaBottom: tr?.bottom || 0,
|
||||
attachmentTop: ar?.top || 0,
|
||||
attachmentBottom: ar?.bottom || 0,
|
||||
footerTop: sr?.top || 0,
|
||||
separated: !!tr && !!ar && ar.top >= tr.bottom - 1,
|
||||
footerSeparated: !!ar && !!sr && sr.top >= ar.bottom - 1,
|
||||
docW: document.documentElement.clientWidth,
|
||||
scrollW: document.documentElement.scrollWidth,
|
||||
composeScrollH: root?.scrollHeight || 0,
|
||||
composeClientH: root?.clientHeight || 0
|
||||
};
|
||||
});
|
||||
check(
|
||||
narrow ? `${name} 正文不少于 12rem` : `${name} 桌面正文占据剩余高度`,
|
||||
m.textareaH >= (narrow ? 191 : 120),
|
||||
`${Math.round(m.textareaH)}px`
|
||||
);
|
||||
check(
|
||||
`${name} 附件不遮挡正文`,
|
||||
m.separated,
|
||||
`正文底=${Math.round(m.textareaBottom)} 附件顶=${Math.round(m.attachmentTop)}`
|
||||
);
|
||||
check(
|
||||
`${name} 操作栏不遮挡附件`,
|
||||
m.footerSeparated,
|
||||
`附件底=${Math.round(m.attachmentBottom)} 操作栏顶=${Math.round(m.footerTop)}`
|
||||
);
|
||||
check(`${name} 无横向溢出`, m.scrollW <= m.docW, `${m.docW}/${m.scrollW}`);
|
||||
return m;
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 320, height: 568 }, { width: 390, height: 844 },
|
||||
{ width: 768, height: 900 }, { width: 820, height: 980 }
|
||||
]) {
|
||||
const page = await open(viewport);
|
||||
const label = `${viewport.width}x${viewport.height}`;
|
||||
check(`${label} 使用单栏底部导航`, await page.locator('nav.narrow-nav').isVisible());
|
||||
await measureCompose(page, label, true);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
{
|
||||
const page = await open({ width: 390, height: 420 });
|
||||
const m = await measureCompose(page, '390x420 键盘态', true);
|
||||
check('键盘态写信页可纵向滚动', m.composeScrollH > m.composeClientH, `${m.composeClientH}/${m.composeScrollH}`);
|
||||
await page.locator('textarea').focus();
|
||||
await page.waitForTimeout(50);
|
||||
const navHidden = await page.locator('nav.narrow-nav').evaluate(el => getComputedStyle(el).display === 'none');
|
||||
check('输入时隐藏底部导航', navHidden);
|
||||
const to = page.locator('input[placeholder*="deepseekharness"]').first();
|
||||
await to.fill('d');
|
||||
await page.waitForTimeout(250);
|
||||
const menu = page.locator('div.absolute.z-20').first();
|
||||
const bounds = await menu.evaluate(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { top: r.top, bottom: r.bottom, vh: window.visualViewport?.height || innerHeight };
|
||||
});
|
||||
check('地址补全不越出可视视口', bounds.top >= 0 && bounds.bottom <= bounds.vh + 1, JSON.stringify(bounds));
|
||||
await page.close();
|
||||
}
|
||||
|
||||
for (const viewport of [{ width: 1024, height: 768 }, { width: 1280, height: 800 }]) {
|
||||
const page = await open(viewport);
|
||||
const label = `${viewport.width}x${viewport.height}`;
|
||||
check(`${label} 使用桌面侧栏`, await page.locator('button[title="新建邮件"]').isVisible());
|
||||
await measureCompose(page, label, false);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
{
|
||||
const page = await open({ width: 1280, height: 800 });
|
||||
for (const mode of ['light', 'dark']) {
|
||||
await page.evaluate(mode => {
|
||||
localStorage.setItem('agentmail.theme', mode);
|
||||
document.documentElement.classList.toggle('dark', mode === 'dark');
|
||||
document.documentElement.style.colorScheme = mode;
|
||||
}, mode);
|
||||
const colors = await page.evaluate(() => ({
|
||||
body: getComputedStyle(document.body).backgroundColor,
|
||||
card: getComputedStyle(document.querySelector('.bg-white')).backgroundColor,
|
||||
text: getComputedStyle(document.querySelector('.text-gray-900') || document.body).color
|
||||
}));
|
||||
check(`${mode} 主题颜色均已解析`, !Object.values(colors).some(c => c === 'rgba(0, 0, 0, 0)'), JSON.stringify(colors));
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
|
||||
console.log(`\n响应式浏览器验收:${passed} 通过,${failures.length} 失败`);
|
||||
if (failures.length) console.log(failures.join('\n'));
|
||||
await browser.close();
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
148
client/electron/test/manual/theme-verify.mjs
Normal file
148
client/electron/test/manual/theme-verify.mjs
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 深色主题手工验收。
|
||||
*
|
||||
* 需要共享 Chromium(CDP 9222)。结构性检查已在 test/theme.test.mjs 里,
|
||||
* 这里验的是**真实渲染出来的对比度** —— 那是唯一能发现白底白字的判据。
|
||||
*
|
||||
* 用法:
|
||||
* ADMIN_USER=jianf ADMIN_PW=... AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||
* node client/electron/test/manual/theme-verify.mjs
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) { pass++; console.log(` 通过 ${name}`); }
|
||||
else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); }
|
||||
};
|
||||
|
||||
/** 相对亮度(WCAG)。用于判断 body 底色是否真的变暗。 */
|
||||
function luminance([r, g, b]) {
|
||||
const f = c => {
|
||||
c /= 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
}
|
||||
|
||||
const parseRgb = s => {
|
||||
const m = String(s).match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
|
||||
try {
|
||||
|
||||
for (const mode of ['light', 'dark']) {
|
||||
console.log(`\n── ${mode} ──`);
|
||||
await page.evaluate(m => {
|
||||
localStorage.setItem('agentmail.theme', m);
|
||||
document.documentElement.classList.toggle('dark', m === 'dark');
|
||||
document.documentElement.style.colorScheme = m;
|
||||
}, mode);
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const body = parseRgb(await page.evaluate(() =>
|
||||
getComputedStyle(document.body).backgroundColor));
|
||||
check(`${mode}: body 底色可读取`, body !== null, String(body));
|
||||
|
||||
if (mode === 'dark') {
|
||||
// 深色下 body 必须是暗的。这一条挂掉说明变量没生效
|
||||
check('dark: body 底色确实是暗的', luminance(body) < 0.2,
|
||||
`亮度 ${luminance(body).toFixed(3)}`);
|
||||
}
|
||||
|
||||
// 遍历可见文本节点,算每个的前景/背景对比度。
|
||||
// 4.5:1 是 WCAG AA 的正文标准;大字放宽到 3:1。
|
||||
const bad = await page.evaluate(() => {
|
||||
const out = [];
|
||||
const els = document.querySelectorAll('button, a, h1, h2, h3, p, span, div, label, li');
|
||||
const lum = ([r, g, b]) => {
|
||||
const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
};
|
||||
const parse = s => {
|
||||
const m = String(s).match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
return m ? [+m[1], +m[2], +m[3]] : null;
|
||||
};
|
||||
/** 往上找第一个不透明的背景。 */
|
||||
const bgOf = el => {
|
||||
let cur = el;
|
||||
while (cur && cur !== document.documentElement) {
|
||||
const cs = getComputedStyle(cur);
|
||||
const c = parse(cs.backgroundColor);
|
||||
const alpha = String(cs.backgroundColor).match(/rgba?\([^)]*,\s*([\d.]+)\)/);
|
||||
if (c && (!alpha || Number(alpha[1]) > 0.85)) return c;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return parse(getComputedStyle(document.body).backgroundColor);
|
||||
};
|
||||
for (const el of els) {
|
||||
// 只看直接含文本的元素
|
||||
const text = [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => n.textContent.trim())
|
||||
.join('');
|
||||
if (!text) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 4 || r.height < 4) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.visibility === 'hidden' || Number(cs.opacity) < 0.75) continue;
|
||||
if (el.matches(':disabled, [aria-disabled="true"]')) continue;
|
||||
// 方向箭头、分隔点与关闭符号是装饰/图标,不按正文文字审计。
|
||||
if (/^[→·×↑↓]+$/.test(text)) continue;
|
||||
const fg = parse(cs.color);
|
||||
const bg = bgOf(el);
|
||||
if (!fg || !bg) continue;
|
||||
const la = lum(fg), lb = lum(bg);
|
||||
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
|
||||
const ratio = (hi + 0.05) / (lo + 0.05);
|
||||
const size = parseFloat(cs.fontSize);
|
||||
const bold = Number(cs.fontWeight) >= 700;
|
||||
const large = size >= 24 || (size >= 18.66 && bold);
|
||||
const need = large ? 3 : 4.5;
|
||||
if (ratio < need) {
|
||||
out.push({
|
||||
text: text.slice(0, 24),
|
||||
ratio: Number(ratio.toFixed(2)),
|
||||
need,
|
||||
fg: cs.color,
|
||||
bg: `rgb(${bg.join(',')})`
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// 禁用态与纯图标已在页面端过滤,其余可见文字都应达到自身 AA 阈值。
|
||||
check(`${mode}: 可见文字全部达到 WCAG AA`, bad.length === 0,
|
||||
bad.slice(0, 4).map(x => `"${x.text}" ${x.ratio}/${x.need}`).join(' | '));
|
||||
if (bad.length) {
|
||||
console.log(` (${bad.length} 处低于 AA 阈值,最差 ${Math.min(...bad.map(x => x.ratio))}:1)`);
|
||||
// 逐条列出来而不只报个数:不知道是哪一处就没法修
|
||||
for (const x of bad.slice(0, 8)) {
|
||||
console.log(` ${x.ratio}:1 (需 ${x.need}) "${x.text}" ${x.fg} on ${x.bg}`);
|
||||
}
|
||||
}
|
||||
|
||||
await page.screenshot({ path: `/tmp/theme-${mode}.png`, fullPage: false });
|
||||
}
|
||||
|
||||
// 刷新后主题必须保持(localStorage + 内联脚本)
|
||||
await page.evaluate(() => localStorage.setItem('agentmail.theme', 'dark'));
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
const stillDark = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark'));
|
||||
check('刷新后深色保持(内联脚本生效)', stillDark);
|
||||
|
||||
check('无 JS 运行时错误', issues.length === 0, issues.slice(0, 3).join(' | '));
|
||||
} finally {
|
||||
await page.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(`\n主题验收:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||
console.log('截图:/tmp/theme-light.png /tmp/theme-dark.png');
|
||||
process.exit(fail ? 1 : 0);
|
||||
583
client/electron/test/manual/ui-sweep.mjs
Normal file
583
client/electron/test/manual/ui-sweep.mjs
Normal file
@ -0,0 +1,583 @@
|
||||
/**
|
||||
* WebUI 能力巡检 —— 覆盖没有专项脚本守着的界面。
|
||||
*
|
||||
* 已有六个专项脚本各守一件事(窄屏覆盖式布局、宽屏三栏回归、深色主题、
|
||||
* 强调色对比度、收件箱分组、地址维度)。它们加起来仍有一大半界面没人看:
|
||||
* 日历三视图与事件编辑器、新建邮件的三段式补全、卡片/列表双视图、对话树、
|
||||
* 转发页、管理页四个 tab、账号页与密钥面板。这个脚本补的就是那部分。
|
||||
*
|
||||
* # 只读原则(它跑在生产库上)
|
||||
*
|
||||
* 一律不做不可逆动作:不发信、不建日程、不停用/删除 Agent、不吊销密钥、
|
||||
* 不改密码。表单只打开与填写,靠「取消」退出。
|
||||
*
|
||||
* 归档是唯一需要解释的例外:点每行的归档图标只调 `requestArchive`,那是个
|
||||
* **纯前端状态**(把该地址记进 `pendingArchive` 以渲染确认框),真正发请求的是
|
||||
* 确认框里的「确认归档」。所以这里点图标 → 验确认框 → 点取消,全程零副作用。
|
||||
*
|
||||
* # 三条定位纪律(每条都是被一次假失败教出来的)
|
||||
*
|
||||
* **一、走 title / placeholder,不走可见文字。** 侧栏图标按钮的可见文字是缩写
|
||||
* (`收件`/`联系`/`用户`),头像按钮的可见文字是用户名前两字 —— 按全名去
|
||||
* `hasText` 一个都点不到。而「新建」在侧栏(新建邮件)与日历头部(新建日程)
|
||||
* 各有一个,靠文字选会点错页面:侧栏那个有 `title`,日历那个没有。
|
||||
*
|
||||
* **二、单字按钮必须精确匹配。** 日历的视图切换是 `月`/`周`/`日` 三个单字按钮,
|
||||
* 而 `hasText` 是子串匹配 —— `日` 会先命中侧栏的 `日历`,于是「切到日视图」
|
||||
* 实际上点了导航,scale 一直停在上一个视图,然后「日视图没有小时刻度」
|
||||
* 报一个假失败。
|
||||
*
|
||||
* **三、判「某控件在不在」不搜 body 全文。** `AddressInput` 补全下拉的 hint
|
||||
* 里就写着「会话别名(new 为新建)」,全文搜索会把补全提示误当成那个只在
|
||||
* 新建会话时出现的输入框。判据要落在 placeholder / inputmode 上。
|
||||
*
|
||||
* 用法:
|
||||
* ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||
* node client/electron/test/manual/ui-sweep.mjs
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
|
||||
let pass = 0;
|
||||
const fails = [];
|
||||
const skips = [];
|
||||
|
||||
const ok = (n, d = '') => { pass++; console.log(` 通过 ${n}${d ? ' — ' + d : ''}`); };
|
||||
const bad = (n, d = '') => { fails.push(n); console.log(` 失败 ${n}${d ? ' — ' + d : ''}`); };
|
||||
const skip = (n, why) => { skips.push(n); console.log(` 跳过 ${n} — ${why}`); };
|
||||
const check = (n, cond, d = '') => (cond ? ok(n, d) : bad(n, d));
|
||||
|
||||
async function section(title, fn) {
|
||||
console.log(`\n─── ${title} ───`);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
bad(`${title} 整段异常`, String(e?.message || e).slice(0, 150));
|
||||
}
|
||||
}
|
||||
|
||||
/** 点一个 CSS 选择器命中的元素;找不到返回 false 而不是抛。 */
|
||||
async function click(sel, wait = 0) {
|
||||
const loc = page.locator(sel).first();
|
||||
if ((await loc.count()) === 0) return false;
|
||||
await loc.click({ timeout: 5000 }).catch(() => {});
|
||||
if (wait) await page.waitForTimeout(wait);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 侧栏导航:可见文字是缩写,title 才是全名。 */
|
||||
const nav = (title, wait = 1200) => click(`button[title="${title}"]`, wait);
|
||||
|
||||
/** 按可见文字点按钮(子串匹配,用于 tab、取消这类长标签)。 */
|
||||
async function clickText(text, wait = 0) {
|
||||
const loc = page.locator('button').filter({ hasText: text }).first();
|
||||
if ((await loc.count()) === 0) return false;
|
||||
await loc.click({ timeout: 5000 }).catch(() => {});
|
||||
if (wait) await page.waitForTimeout(wait);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 按可见文字**精确**点按钮。单字标签(月/周/日)必须走这个。 */
|
||||
async function clickExact(text, wait = 0) {
|
||||
const loc = page.locator('button').filter({ hasText: new RegExp(`^${text}$`) }).first();
|
||||
if ((await loc.count()) === 0) return false;
|
||||
await loc.click({ timeout: 5000 }).catch(() => {});
|
||||
if (wait) await page.waitForTimeout(wait);
|
||||
return true;
|
||||
}
|
||||
|
||||
const has = (sel) => page.locator(sel).count().then((n) => n > 0);
|
||||
const bodyText = () => page.evaluate(() => document.body.innerText);
|
||||
const titles = () =>
|
||||
page.evaluate(() =>
|
||||
[...document.querySelectorAll('button[title]')].map((b) => b.title).slice(0, 24));
|
||||
|
||||
await page.waitForSelector('button', { timeout: 20000 });
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('实时推送与连接状态', async () => {
|
||||
check('EventSource 可用', await page.evaluate(() => typeof window.EventSource === 'function'));
|
||||
// 指示器只在异常时出声是合理设计 —— 这里要的是「不会在正常时误报断线」
|
||||
const shown = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('*')]
|
||||
.filter((e) => e.children.length === 0)
|
||||
.map((e) => (e.textContent || '').trim())
|
||||
.find((t) => /已断开|连接中|重连中/.test(t)) || null);
|
||||
check('正常状态下不误报断线', !shown, shown ? `显示了「${shown}」` : '');
|
||||
check('侧栏头像上挂着连接状态点', await has('button[title*="点击管理账号"] span'));
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('日历:月 / 周 / 日三视图 + 农历', async () => {
|
||||
if (!(await nav('日历', 3200))) return bad('找不到日历入口', (await titles()).join(' / '));
|
||||
|
||||
// 月视图与周视图都是 grid-cols-7,靠子元素数区分:月 = 7 表头 + 42 格,周 = 7 列
|
||||
const cells = () => page.locator('[class*="grid-cols-7"] > *').count();
|
||||
const hourMarks = async () => ((await bodyText()).match(/\b([01]\d|2[0-3]):00\b/g) || []).length;
|
||||
|
||||
const month = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
weekHeader: /日[\s\S]{0,8}一[\s\S]{0,8}二[\s\S]{0,8}三/.test(txt),
|
||||
lunar: (txt.match(/初[一二三四五六七八九十]|十[一二三四五]|廿[一二三四五六七八九]|正月|腊月|冬月/g) || []).length,
|
||||
scaleBtns: [...document.querySelectorAll('button')]
|
||||
.map((b) => (b.innerText || '').trim())
|
||||
.filter((t) => ['月', '周', '日'].includes(t)),
|
||||
};
|
||||
});
|
||||
check('月视图有星期表头', month.weekHeader);
|
||||
check('月视图是整月网格', (await cells()) >= 42, `${await cells()} 个格子`);
|
||||
check('显示农历', month.lunar > 0, `命中 ${month.lunar} 处`);
|
||||
check('有三档视图切换', month.scaleBtns.length === 3, month.scaleBtns.join(','));
|
||||
check('月视图不按小时排', (await hourMarks()) === 0);
|
||||
|
||||
// 周视图:7 列按天,每列头显示 M.D。它刻意**不**按小时排 ——
|
||||
// 一周 × 24 小时的格子在任何屏宽下都读不了,按小时是日视图的事
|
||||
if (await clickExact('周', 1500)) {
|
||||
const dates = ((await bodyText()).match(/\b\d{1,2}\.\d{1,2}\b/g) || []).length;
|
||||
check('周视图是 7 列', (await cells()) === 7, `${await cells()} 列`);
|
||||
check('周视图每列有日期', dates >= 7, `${dates} 个 M.D`);
|
||||
check('周视图不按小时排', (await hourMarks()) === 0);
|
||||
} else skip('周视图', '按钮不存在');
|
||||
|
||||
// 日视图:唯一按小时排的视图,HOURS 是完整 24 行
|
||||
if (await clickExact('日', 1500)) {
|
||||
const marks = await hourMarks();
|
||||
check('日视图有 24 行小时刻度', marks >= 24, `${marks} 个 HH:00`);
|
||||
} else skip('日视图', '按钮不存在');
|
||||
|
||||
await clickExact('月', 1200);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('日历:新建事件(打开后取消,不保存)', async () => {
|
||||
// 侧栏的「新建」有 title="新建邮件",日历头部那个没有 title —— 用它区分
|
||||
const calNew = page.locator('button:not([title])').filter({ hasText: /^新建$/ }).first();
|
||||
if ((await calNew.count()) === 0) return skip('新建事件', '找不到日历的新建按钮');
|
||||
await calNew.click().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const ed = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
const inputs = [...document.querySelectorAll('input, select, textarea')];
|
||||
return {
|
||||
count: inputs.length,
|
||||
kinds: [...new Set(inputs.map((i) => i.type || i.tagName.toLowerCase()))].join(','),
|
||||
title: /标题/.test(txt),
|
||||
time: !!document.querySelector('input[type=datetime-local]'),
|
||||
remind: /提前|提醒/.test(txt),
|
||||
recur: /重复|不重复|每天|每周|每月|每年/.test(txt),
|
||||
lunar: /农历/.test(txt),
|
||||
preview: /Agent 会收到/.test(txt),
|
||||
// 附件要挂在某个 event_id 上才能上传,所以新建态**刻意**没有附件段
|
||||
// (`{editing && ...}`)—— 存盘后再打开才有
|
||||
attach: /附件(随提醒邮件一起发出)/.test(txt),
|
||||
// 单收件人时不该出现投递模式:那是多收件人才有的选择
|
||||
deliveryModeEarly: /分别发送|一起发送/.test(txt),
|
||||
};
|
||||
});
|
||||
check('有输入控件', ed.count >= 3, `${ed.count} 个(${ed.kinds})`);
|
||||
check('有标题字段', ed.title);
|
||||
check('有 datetime-local 时间选择', ed.time);
|
||||
check('有提醒提前量', ed.remind);
|
||||
check('有重复规则', ed.recur);
|
||||
check('重复规则含农历选项', ed.lunar);
|
||||
check('有「Agent 会收到」正文预览', ed.preview);
|
||||
check('新建态不显示附件段(附件需先有 event_id)', !ed.attach);
|
||||
check('收件人不足两个时不显示投递模式', !ed.deliveryModeEarly);
|
||||
|
||||
// 加两个收件人:`unusedAgents` 快捷按钮的文字是 `+ <agent名>`
|
||||
const added = await page.evaluate(() => {
|
||||
const btns = [...document.querySelectorAll('button')]
|
||||
.filter((b) => /^\+\s+\S+$/.test((b.innerText || '').trim()));
|
||||
btns.slice(0, 2).forEach((b) => b.click());
|
||||
return btns.slice(0, 2).map((b) => (b.innerText || '').trim());
|
||||
});
|
||||
await page.waitForTimeout(900);
|
||||
if (added.length < 2) {
|
||||
skip('多收件人投递模式', `只找到 ${added.length} 个快捷收件人按钮`);
|
||||
} else {
|
||||
const dm = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
separate: /分别发送/.test(txt),
|
||||
together: /一起发送/.test(txt),
|
||||
radios: document.querySelectorAll('input[type=radio]').length,
|
||||
// 两种模式的后果必须写清楚,否则人分不出该选哪个
|
||||
explains: /互相看不到/.test(txt) && /共享同一条线索/.test(txt),
|
||||
};
|
||||
});
|
||||
check('多收件人出现「分别发送」', dm.separate, added.join(' '));
|
||||
check('多收件人出现「一起发送」', dm.together);
|
||||
check('投递模式是单选而非多选', dm.radios >= 2, `${dm.radios} 个 radio`);
|
||||
check('两种模式都说明了后果', dm.explains);
|
||||
}
|
||||
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('日历:打开已有事件(附件段只在编辑态出现)', async () => {
|
||||
// 月视图格子是 div,事件胶囊才是带 title 的 button
|
||||
const chip = page.locator('[class*="grid-cols-7"] button[title]').first();
|
||||
if ((await chip.count()) === 0) return skip('已有事件编辑态', '本月没有事件可点');
|
||||
const label = await chip.getAttribute('title');
|
||||
await chip.click().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const e = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
attach: /附件(随提醒邮件一起发出)/.test(txt),
|
||||
// 暂停/取消的事件不再触发提醒 —— 状态必须可改,否则只能删了重建
|
||||
status: !!document.querySelector('select') && /暂停|已取消/.test(txt),
|
||||
del: [...document.querySelectorAll('button')].some((b) => /删除/.test(b.innerText || '')),
|
||||
};
|
||||
});
|
||||
check('编辑已有事件时出现附件段', e.attach, label || '');
|
||||
check('可改事件状态', e.status);
|
||||
check('有删除入口(未点击)', e.del);
|
||||
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('新建邮件:整页 + 三段式补全', async () => {
|
||||
await nav('收件箱', 1000);
|
||||
if (!(await nav('新建邮件', 1500))) return bad('找不到新建邮件入口', (await titles()).join(' / '));
|
||||
|
||||
const c = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
// 「新建邮件 = 右侧整页而非弹窗」是明确的设计决定:
|
||||
// 判据是没有铺满视口的半透明遮罩
|
||||
overlay: !!document.querySelector('div.fixed.inset-0[class*="bg-black"]'),
|
||||
subject: /主题/.test(txt),
|
||||
cc: /抄送/.test(txt),
|
||||
attach: /附件/.test(txt),
|
||||
preview: /预览/.test(txt),
|
||||
};
|
||||
});
|
||||
check('是整页而非弹窗', !c.overlay);
|
||||
check('有主题', c.subject);
|
||||
check('有正文 textarea', await has('textarea'));
|
||||
check('有抄送', c.cc);
|
||||
check('有附件入口', c.attach);
|
||||
check('正文有 Markdown 预览', c.preview);
|
||||
|
||||
const addr = page.locator('input[spellcheck=false]').first();
|
||||
const typeAddr = async (v) => {
|
||||
await addr.click().catch(() => {});
|
||||
await addr.fill(v).catch(() => {});
|
||||
await page.waitForTimeout(1300);
|
||||
};
|
||||
// 补全下拉是 button 列表,取每项首行当候选
|
||||
const options = () =>
|
||||
page.evaluate(() =>
|
||||
[...document.querySelectorAll('div.absolute.z-20 button')]
|
||||
.map((b) => (b.innerText || '').trim().split('\n')[0]));
|
||||
|
||||
await typeAddr('p');
|
||||
const s1 = await options();
|
||||
check('name 段有候选', s1.includes('pi'), `${s1.length} 个:${s1.slice(0, 4).join(' ')}`);
|
||||
|
||||
await typeAddr('pi@');
|
||||
const s2 = await options();
|
||||
check('path 段有候选', s2.some((o) => o.startsWith('/')), s2.slice(0, 3).join(' '));
|
||||
|
||||
await typeAddr('pi@/home/program/agentmail.');
|
||||
const s3 = await options();
|
||||
check('session 段候选含保留字 new', s3.includes('new'), `${s3.length} 个候选`);
|
||||
|
||||
// `session_alias` 与 `max_rounds` 只在本次投递新建会话时生效 —— 界面必须据此
|
||||
// 显隐,否则人会以为续谈时也能改这两个约定
|
||||
await typeAddr('pi@/home/program/agentmail.new');
|
||||
await page.keyboard.press('Escape'); // 关掉补全,免得它盖住下面的字段
|
||||
await page.waitForTimeout(500);
|
||||
check('.new 时出现「会话别名」输入', await has('input[placeholder="refactor-auth"]'));
|
||||
check('.new 时出现「往返预算」输入', await has('input[inputmode="numeric"]'));
|
||||
|
||||
await typeAddr('pi@/home/program/agentmail.子任务-跑一条命令');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
check('续谈已有会话时隐藏「会话别名」', !(await has('input[placeholder="refactor-auth"]')));
|
||||
check('续谈已有会话时隐藏「往返预算」', !(await has('input[inputmode="numeric"]')));
|
||||
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('对话树 + 转发页(填完取消)', async () => {
|
||||
await nav('收件箱', 1300);
|
||||
|
||||
// 收件箱按会话分组:组头与邮件行的 className 都含 `w-full text-left`,
|
||||
// 靠「点完有没有出现含『发件』的 dl」区分
|
||||
let opened = false;
|
||||
for (let round = 0; round < 3 && !opened; round++) {
|
||||
const btns = await page.$$('button.w-full.text-left');
|
||||
for (const b of btns) {
|
||||
await b.click().catch(() => {});
|
||||
await page.waitForTimeout(340);
|
||||
opened = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('dl')].some((dl) => dl.textContent.includes('发件')));
|
||||
if (opened) break;
|
||||
}
|
||||
}
|
||||
if (!opened) return bad('打开一封邮件', '没找到可点开的邮件行');
|
||||
ok('打开一封邮件');
|
||||
|
||||
if (await clickText('对话树', 1800)) {
|
||||
const tree = await page.evaluate(() => ({
|
||||
back: [...document.querySelectorAll('button')].some((b) => /返回|关闭|收起/.test(b.innerText || '')),
|
||||
// 每个节点按 depth 缩进:style={{ paddingLeft: indent }}
|
||||
nodes: document.querySelectorAll('[style*="padding-left"]').length,
|
||||
}));
|
||||
check('对话树有返回出口', tree.back);
|
||||
check('对话树按层级缩进', tree.nodes > 0, `${tree.nodes} 个节点`);
|
||||
for (const t of ['返回', '关闭', '收起']) if (await clickText(t, 1000)) break;
|
||||
} else skip('对话树', '按钮不存在');
|
||||
|
||||
if (await clickText('转发', 1300)) {
|
||||
const f = await page.evaluate(() => ({
|
||||
inputs: document.querySelectorAll('input').length,
|
||||
cc: /抄送/.test(document.body.innerText),
|
||||
hint: /转发|Fwd|原文|新收件人/.test(document.body.innerText),
|
||||
}));
|
||||
check('转发页有收件人输入', f.inputs > 0, `${f.inputs} 个输入框`);
|
||||
check('转发页有抄送', f.cc);
|
||||
check('转发页有转发语境提示', f.hint);
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
} else skip('转发', '按钮不存在');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('管理页四个 tab(只看不动)', async () => {
|
||||
if (!(await nav('用户管理', 1700))) return bad('找不到管理入口', (await titles()).join(' / '));
|
||||
|
||||
const tabs = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('button')]
|
||||
.map((b) => (b.innerText || '').replace(/\s+/g, ' ').trim())
|
||||
.filter((t) => /^(用户管理|Agent 密钥|Agent 管理|模型范围)/.test(t)));
|
||||
check('四个 tab 都在', tabs.length >= 4, tabs.join(' | '));
|
||||
|
||||
const u = await page.evaluate(() => ({
|
||||
admin: /jianf/.test(document.body.innerText),
|
||||
role: /管理员/.test(document.body.innerText),
|
||||
create: [...document.querySelectorAll('button')].some((b) => /新建用户/.test(b.innerText || '')),
|
||||
}));
|
||||
check('用户列表显示管理员', u.admin);
|
||||
check('显示角色徽标', u.role);
|
||||
check('有新建用户入口', u.create);
|
||||
|
||||
if (await clickText('Agent 密钥', 1500)) {
|
||||
const k = await page.evaluate(() => {
|
||||
const codes = [...document.querySelectorAll('code')].map((c) => (c.textContent || '').trim());
|
||||
return {
|
||||
heading: /Agent 接入密钥/.test(document.body.innerText),
|
||||
n: codes.length,
|
||||
max: Math.max(0, ...codes.map((c) => c.length)),
|
||||
sample: codes.slice(0, 3),
|
||||
};
|
||||
});
|
||||
check('标题是「Agent 接入密钥」', k.heading);
|
||||
check('列出了密钥', k.n > 0, `${k.n} 条`);
|
||||
// 密钥全文只在刚签发时展示一次,列表只该给 token_hint
|
||||
check('列表不泄露密钥全文', k.max <= 24, `最长 ${k.max} 字符:${k.sample.join(' ')}`);
|
||||
|
||||
// 三种生命周期在创建表单里,表单默认收起 —— 不展开是看不到的
|
||||
if (await clickText('新建密钥', 1100)) {
|
||||
const f = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
types: ['长期', '一次性', '限时'].filter((t) => txt.includes(t)),
|
||||
hints: /永不过期/.test(txt) && /首次使用后立即失效/.test(txt),
|
||||
// Agent 面板独有的两项:绑定 Agent 名、登记插件本地生成的密钥
|
||||
agentOnly: document.querySelectorAll(
|
||||
'input[placeholder*="绑定到 Agent"], input[placeholder*="登记插件"]').length,
|
||||
};
|
||||
});
|
||||
check('三种生命周期都在', f.types.length === 3, f.types.join(','));
|
||||
check('每种都写了含义', f.hints);
|
||||
check('Agent 面板有绑定/登记两项', f.agentOnly === 2, `${f.agentOnly} 个`);
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
ok('未创建任何密钥(点了取消)');
|
||||
} else skip('密钥生命周期', '找不到新建密钥按钮');
|
||||
} else skip('Agent 密钥 tab', '按钮不存在');
|
||||
|
||||
if (await clickText('Agent 管理', 1500)) {
|
||||
const a = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
agents: ['pi', 'opencode', 'dsh', 'homeagent'].filter((n) => new RegExp(`\\b${n}\\b`).test(txt)),
|
||||
online: (txt.match(/在线/g) || []).length,
|
||||
budgetInputs: document.querySelectorAll('input[inputmode=numeric]').length,
|
||||
disable: document.querySelectorAll('button[title*="停用"], button[title*="恢复"]').length,
|
||||
del: document.querySelectorAll('button[title*="彻底删除"]').length,
|
||||
// 停用/恢复的后果必须写在界面上(密钥不会自动回来)。少了这句,
|
||||
// 实测代价是 opencode 拿旧密钥重试 18 小时、gateway 日志 2690 次 401
|
||||
warnsKeys: /密钥不会自动回来|重新签发/.test(txt),
|
||||
};
|
||||
});
|
||||
check('四个 Agent 都列出来了', a.agents.length === 4, a.agents.join(','));
|
||||
check('显示在线状态', a.online > 0, `${a.online} 处「在线」`);
|
||||
check('有默认预算输入框', a.budgetInputs >= 4, `${a.budgetInputs} 个`);
|
||||
check('有停用/恢复入口', a.disable > 0, `${a.disable} 个`);
|
||||
check('有删除入口', a.del > 0, `${a.del} 个`);
|
||||
check('说明了恢复后须重新签发密钥', a.warnsKeys);
|
||||
ok('未点击任何停用/删除(只读巡检)');
|
||||
} else skip('Agent 管理 tab', '按钮不存在');
|
||||
|
||||
if (await clickText('模型范围', 1500)) {
|
||||
const before = (await bodyText()).length;
|
||||
// 明确展开 opencode:本机只有它的目录足够大(24 个模型)。
|
||||
// 随便点第一行可能落在 homeagent —— 它一个模型都没上报,
|
||||
// 于是「清单长什么样」根本验不到。
|
||||
let expanded = false;
|
||||
for (const r of await page.$$('button')) {
|
||||
if ((await r.innerText().catch(() => '')).trim().startsWith('opencode')) {
|
||||
await r.click().catch(() => {});
|
||||
expanded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!expanded) return skip('模型清单', '找不到 opencode 那一行');
|
||||
await page.waitForTimeout(1900);
|
||||
|
||||
const m = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
len: txt.length,
|
||||
catalogHeader: /平台上报的模型/.test(txt),
|
||||
// 「配可用模型必须是选择而非手打」:清单是一排可切换的等宽按钮,
|
||||
// 不是让人敲模型名的输入框
|
||||
toggles: [...document.querySelectorAll('button')]
|
||||
.filter((b) => /font-mono/.test(b.className || '')).length,
|
||||
freeText: [...document.querySelectorAll('input')]
|
||||
.filter((i) => /model|模型/i.test(i.placeholder || '')).length,
|
||||
order: /尝试顺序|按序尝试/.test(txt),
|
||||
unrestricted: /不限定/.test(txt),
|
||||
};
|
||||
});
|
||||
check('展开后内容变多', m.len > before, `${before} → ${m.len} 字符`);
|
||||
check('列出平台上报的模型清单', m.catalogHeader);
|
||||
check('模型是点选而非手打', m.toggles > 0 && m.freeText === 0,
|
||||
`${m.toggles} 个可切换项 / ${m.freeText} 个自由输入`);
|
||||
check('说明了顺序即优先级', m.order);
|
||||
check('说明了不选 = 不限定', m.unrestricted);
|
||||
} else skip('模型范围 tab', '按钮不存在');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('账号页:密钥面板 + 主题三态', async () => {
|
||||
// 账号入口是头像按钮,可见文字是用户名前两字
|
||||
if (!(await click('button[title*="点击管理账号"]', 1700))) {
|
||||
return bad('找不到账号入口', (await titles()).join(' / '));
|
||||
}
|
||||
|
||||
const a = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
profile: /基本资料/.test(txt),
|
||||
pwd: /修改密码/.test(txt),
|
||||
clientKeys: /客户端连接密钥/.test(txt),
|
||||
cannotRegister: /不能用于注册 Agent/.test(txt),
|
||||
logout: /退出登录/.test(txt),
|
||||
themes: [...document.querySelectorAll('button[title]')]
|
||||
.map((b) => b.title)
|
||||
.filter((t) => /始终使用浅色|始终使用深色|随系统/.test(t)),
|
||||
};
|
||||
});
|
||||
check('显示基本资料', a.profile);
|
||||
check('有修改密码(未提交)', a.pwd);
|
||||
check('有客户端连接密钥面板', a.clientKeys);
|
||||
check('写明用户密钥不能注册 Agent', a.cannotRegister);
|
||||
check('主题是三态而非开关', a.themes.length === 3, a.themes.join(' / '));
|
||||
check('有退出登录(未点击)', a.logout);
|
||||
|
||||
// 同一个 KeyPanel 的 user variant:不该出现 Agent 专属的两项
|
||||
if (await clickText('新建密钥', 1100)) {
|
||||
const n = await page.evaluate(() => document.querySelectorAll(
|
||||
'input[placeholder*="绑定到 Agent"], input[placeholder*="登记插件"]').length);
|
||||
check('用户面板没有绑定/登记项', n === 0, `${n} 个`);
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
} else skip('用户密钥面板 variant 差异', '找不到新建密钥按钮');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('联系人页:列表 / 卡片双视图 + 归档确认框', async () => {
|
||||
// 中间栏的列表/卡片切换属于 ContactPanel,而它只在「联系人」视图挂载 ——
|
||||
// 在收件箱里那一栏是 MailList,找不到这个按钮
|
||||
if (!(await nav('联系人', 1600))) return bad('找不到联系人入口', (await titles()).join(' / '));
|
||||
|
||||
const c = await page.evaluate(() => ({
|
||||
heading: /联系人/.test(document.body.innerText),
|
||||
archiveBtns: document.querySelectorAll('button[title^="归档该"]').length,
|
||||
paths: (document.body.innerText.match(/\/home\/|\/tmp\/|\/root/g) || []).length,
|
||||
viewArchived: [...document.querySelectorAll('button')].some((b) => (b.innerText || '').trim() === '归档'),
|
||||
}));
|
||||
check('中间栏标题是「联系人」', c.heading);
|
||||
check('列出联系人行', c.archiveBtns > 0, `${c.archiveBtns} 行`);
|
||||
check('行内显示工作区路径', c.paths > 0, `${c.paths} 处路径`);
|
||||
check('头部有「查看已归档」开关', c.viewArchived);
|
||||
|
||||
if (!(await click('button[title="切换到卡片视图"]', 1700))) {
|
||||
skip('卡片视图', '没找到切到卡片的按钮');
|
||||
} else {
|
||||
const card = await page.evaluate(() => ({
|
||||
heading: /工作列表/.test(document.body.innerText),
|
||||
// 卡片带预算徽标:title 形如「往返预算:已用 3/20」
|
||||
budgetChip: document.querySelectorAll('[title^="往返预算"]').length,
|
||||
pref: localStorage.getItem('agentmail.contactView'),
|
||||
}));
|
||||
check('切到卡片后标题变「工作列表」', card.heading);
|
||||
check('卡片有往返预算徽标', card.budgetChip > 0, `${card.budgetChip} 个`);
|
||||
check('视图偏好落 localStorage', card.pref === 'card', `agentmail.contactView=${card.pref}`);
|
||||
|
||||
check('能切回列表视图', await click('button[title="切换到列表视图"]', 1400));
|
||||
const back = await page.evaluate(() => ({
|
||||
heading: /联系人/.test(document.body.innerText),
|
||||
pref: localStorage.getItem('agentmail.contactView'),
|
||||
}));
|
||||
check('切回后标题恢复「联系人」', back.heading);
|
||||
check('偏好跟着回到 list', back.pref === 'list', `agentmail.contactView=${back.pref}`);
|
||||
}
|
||||
|
||||
if (c.archiveBtns === 0) return skip('归档确认框', '没有归档按钮');
|
||||
// requestArchive 只写前端状态(为渲染确认框),不发请求 —— 点它是安全的
|
||||
await click('button[title^="归档该"]', 1000);
|
||||
const confirm = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
// 确认框是唯一渲染 contact.address 全文的地方,顺带验三段地址成形
|
||||
addr: (txt.match(/[a-z][\w.-]*@\/[^\s??]+/) || [null])[0],
|
||||
explains: /session 将被归档/.test(txt),
|
||||
confirmBtn: [...document.querySelectorAll('button')].some((b) => /确认归档/.test(b.innerText || '')),
|
||||
cancelBtn: [...document.querySelectorAll('button')].some((b) => (b.innerText || '').trim() === '取消'),
|
||||
};
|
||||
});
|
||||
check('确认框显示完整三段地址', !!confirm.addr && confirm.addr.includes('.'), confirm.addr || '(没找到)');
|
||||
check('确认框说明了后果', confirm.explains);
|
||||
check('确认框有确认按钮(未点击)', confirm.confirmBtn);
|
||||
check('确认框可取消', confirm.cancelBtn);
|
||||
if (confirm.cancelBtn) {
|
||||
await clickText('取消', 900);
|
||||
const gone = await page.evaluate(() =>
|
||||
![...document.querySelectorAll('button')].some((b) => /确认归档/.test(b.innerText || '')));
|
||||
check('取消后确认框消失,未归档任何会话', gone);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
console.log('\n─── JS 运行时错误 ───');
|
||||
if (issues.length === 0) ok('无 pageerror / console.error');
|
||||
else {
|
||||
bad(`${issues.length} 条运行时问题`);
|
||||
issues.slice(0, 8).forEach((i) => console.log(' ', i));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`\n═══ ${pass} 通过 / ${fails.length} 失败 / ${skips.length} 跳过 ═══`);
|
||||
if (fails.length) { console.log('失败项:'); fails.forEach((f) => console.log(' -', f)); }
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
71
client/electron/test/manual/wide-regression.mjs
Normal file
71
client/electron/test/manual/wide-regression.mjs
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 宽屏回归:窄屏修复不能把桌面布局改坏。
|
||||
*
|
||||
* 特别是两个只该在窄屏生效的东西:
|
||||
* - `.tap` 的伪元素命中区(桌面密排工具栏里会互相重叠)
|
||||
* - `BackButton`(宽屏列表与详情并排,返回没有意义)
|
||||
*
|
||||
* 用法:ADMIN_PW=<密码> node client/electron/test/manual/wide-regression.mjs
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
const failed = [];
|
||||
const chk = (n, ok, note = '') => {
|
||||
console.log(` ${ok ? '通过' : '失败'} ${n}${note ? ' — ' + note : ''}`);
|
||||
if (!ok) failed.push(n);
|
||||
};
|
||||
|
||||
console.log('宽屏回归(1280px):');
|
||||
|
||||
const cols = await page.evaluate(() => {
|
||||
const root = document.querySelector('#root > div');
|
||||
return { n: root?.children.length, first: root?.children[0]?.className?.toString().slice(0, 30) };
|
||||
});
|
||||
chk('仍是三栏并排', cols.n === 3, `栏数=${cols.n} 首栏=${cols.first}`);
|
||||
|
||||
// 常驻侧栏是宽屏唯一的退出入口(账号页也有,两处都要在)
|
||||
const side = await page.evaluate(() => {
|
||||
const s = document.querySelector('#root > div > div');
|
||||
const btns = s
|
||||
? [...s.querySelectorAll('button')].map(b =>
|
||||
(b.getAttribute('title') || b.textContent || '').trim().slice(0, 12)
|
||||
)
|
||||
: [];
|
||||
return { w: s ? Math.round(s.getBoundingClientRect().width) : 0, btns };
|
||||
});
|
||||
chk('常驻侧栏仍有退出登录', side.btns.some(b => b.includes('退出')), `宽=${side.w}`);
|
||||
|
||||
// 宽屏不该出现返回按钮
|
||||
// 邮件行是 <button class="w-full text-left ...">
|
||||
const rows = page.locator('button.w-full.text-left');
|
||||
if ((await rows.count()) > 0) await rows.first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
const backN = await page.locator('button[aria-label="返回"], button[aria-label="会话"]').count();
|
||||
chk('没有返回按钮', backN === 0, `${backN} 个`);
|
||||
|
||||
// .tap 只在 max-width:767px 生效
|
||||
const tapWide = await page.evaluate(() => {
|
||||
const b = [...document.querySelectorAll('.tap')].find(x => x.getBoundingClientRect().height > 0);
|
||||
if (!b) return null;
|
||||
const cs = getComputedStyle(b, '::after');
|
||||
return { content: cs.content, w: cs.width, h: cs.height };
|
||||
});
|
||||
chk(
|
||||
'.tap 伪元素在宽屏不生效',
|
||||
!tapWide || tapWide.content === 'none' || tapWide.w === 'auto',
|
||||
JSON.stringify(tapWide)
|
||||
);
|
||||
|
||||
const of = await page.evaluate(() => ({
|
||||
d: document.documentElement.clientWidth,
|
||||
s: document.documentElement.scrollWidth
|
||||
}));
|
||||
chk('无横向溢出', of.s <= of.d, `doc=${of.d} scroll=${of.s}`);
|
||||
|
||||
console.log('\nissues:', issues.length ? issues : '无');
|
||||
console.log(failed.length === 0 ? '\n宽屏回归:全部通过' : `\n宽屏回归:${failed.length} 项失败`);
|
||||
|
||||
await page.close();
|
||||
await browser.close();
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
43
client/electron/test/markdown-xss.test.mjs
Normal file
43
client/electron/test/markdown-xss.test.mjs
Normal file
@ -0,0 +1,43 @@
|
||||
// 回归测试:确认邮件正文的 Markdown 渲染不会执行注入的脚本。
|
||||
// react-markdown 默认不解析 raw HTML(无 rehype-raw),且用 defaultUrlTransform
|
||||
// 清空非 http(s)/mailto 协议的 URL —— 本测试守住这两个前提,防止日后有人
|
||||
// 为了「支持 HTML 邮件」顺手加上 rehype-raw 而不自觉地开了 XSS 口子。
|
||||
//
|
||||
// 运行:node test/markdown-xss.test.mjs
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import React from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// 只有【真实标签】里的危险内容才算漏。
|
||||
// 注意不能直接搜 onerror=:raw HTML 被转义成 <img … onerror=" 后,
|
||||
// 文本里仍含该字样但已无执行能力,按标签边界匹配才不会误报。
|
||||
const dangerous = /<(script|iframe|object|embed)\b|<[a-z][^>]*\son[a-z]+\s*=|<[a-z][^>]*(href|src)\s*=\s*"javascript:/i;
|
||||
|
||||
const payloads = [
|
||||
'<script>alert(1)</script>',
|
||||
'<img src=x onerror="alert(1)">',
|
||||
'[click](javascript:alert(1))',
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<iframe src="https://evil.com"></iframe>',
|
||||
')',
|
||||
'<div onmouseover="alert(1)">hover</div>',
|
||||
'[ok](https://example.com)',
|
||||
'**bold** `code`',
|
||||
];
|
||||
|
||||
let leaks = 0;
|
||||
for (const p of payloads) {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(Markdown, { remarkPlugins: [remarkGfm] }, p)
|
||||
);
|
||||
const bad = dangerous.test(html);
|
||||
if (bad) leaks++;
|
||||
console.log((bad ? 'LEAK ' : 'safe '), JSON.stringify(p), '->', html.slice(0, 80));
|
||||
}
|
||||
if (leaks > 0) {
|
||||
console.error(`\n失败:${leaks} 处 XSS 泄漏`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n通过:raw HTML 被转义,javascript: URL 被清空');
|
||||
185
client/electron/test/narrow-layout.test.mjs
Normal file
185
client/electron/test/narrow-layout.test.mjs
Normal file
@ -0,0 +1,185 @@
|
||||
// 窄屏布局的结构性回归测试。
|
||||
//
|
||||
// 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。
|
||||
// 这里守住几条真正会坏掉的不变量。
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const read = p => readFileSync(new URL(p, import.meta.url), 'utf8');
|
||||
let failed = 0;
|
||||
const check = (name, cond, detail = '') => {
|
||||
if (cond) {
|
||||
console.log(` 通过 ${name}`);
|
||||
} else {
|
||||
console.error(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
|
||||
failed++;
|
||||
}
|
||||
};
|
||||
|
||||
console.log('窄屏布局回归:');
|
||||
|
||||
// 1) 覆盖式而非分栏:NarrowStack 必须同时挂载 base 与 overlay
|
||||
const stack = read('../src/components/NarrowStack.tsx');
|
||||
check(
|
||||
'覆盖层与底层同时在 DOM 里(底层不卸载,滚动位置与选中态才能保留)',
|
||||
stack.includes('{base}') && stack.includes('{overlay}') && stack.includes('absolute inset-0')
|
||||
);
|
||||
check(
|
||||
'关闭时延迟卸载,退出动画才有东西可播',
|
||||
/setTimeout\(/.test(stack) && stack.includes('setMounted(false)')
|
||||
);
|
||||
check(
|
||||
'入场用双层 rAF,避免与挂载合帧导致 transition 不触发',
|
||||
(stack.match(/requestAnimationFrame/g) || []).length >= 2
|
||||
);
|
||||
check(
|
||||
'尊重 prefers-reduced-motion',
|
||||
stack.includes('motion-reduce:transition-none')
|
||||
);
|
||||
|
||||
// 2) 手机与竖屏平板统一用单栏;三栏只在 Tailwind lg(1024px)启用。
|
||||
const narrowHook = read('../src/hooks/useIsNarrow.ts');
|
||||
check('JS 单栏断点与 lg 一致', narrowHook.includes('(max-width: 1023px)'));
|
||||
for (const f of ['MailList', 'PermissionList', 'ContactPanel', 'CalendarView']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
const narrowFullWidth = src.includes('w-full');
|
||||
const bareFixed = (src.match(/(?<![-\w])w-\[(\d+)px\]/g) || []).filter(m => {
|
||||
const px = Number(m.match(/\d+/)?.[0] || 0);
|
||||
return px >= 200 && !src.includes('lg:' + m);
|
||||
});
|
||||
check(
|
||||
`${f} 平板单栏全宽,固定宽度仅在 lg 之后`,
|
||||
narrowFullWidth && bareFixed.length === 0 && !/md:w-\[/.test(src),
|
||||
bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '存在 md 固定栏或缺少 w-full'
|
||||
);
|
||||
}
|
||||
|
||||
// 3) 详情页必须有返回出口,否则窄屏进去就出不来
|
||||
const view = read('../src/components/MailView.tsx');
|
||||
check('邮件详情有返回按钮', view.includes('<BackButton'));
|
||||
const compose = read('../src/components/ComposePage.tsx');
|
||||
check('写信页有返回出口', compose.includes('cancelCompose') && compose.includes('NarrowOnly'));
|
||||
|
||||
// 4) 窄屏专属控件不能只靠 CSS 隐藏 —— 那样宽屏 Tab 会聚焦到看不见的按钮。
|
||||
// 注释里提到 md:hidden 是在解释「为什么不用它」,所以先剥掉注释再查。
|
||||
const stripComments = src =>
|
||||
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
for (const f of ['BackButton', 'NarrowOnly']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
const code = stripComments(src);
|
||||
check(
|
||||
`${f} 用 useIsNarrow 条件渲染而非 md:hidden`,
|
||||
src.includes('useIsNarrow') && /\bnull\b/.test(code) && !code.includes('md:hidden')
|
||||
);
|
||||
}
|
||||
|
||||
// 5) 底部导航要避开 iPhone 手势条
|
||||
const nav = read('../src/components/NarrowNav.tsx');
|
||||
check('底部导航留了安全区内边距', nav.includes('safe-area-inset-bottom'));
|
||||
|
||||
// 5.1) 抽屉式侧栏已删。
|
||||
// 它装的六项与底部导航完全重复,唯一独有的是退出登录;代价是 z-50 的
|
||||
// fixed 层铺满视口高度,把底部导航最左那一项盖住点不到
|
||||
// (实测 elementFromPoint 命中抽屉里的 SVG)。
|
||||
const app = read('../src/App.tsx');
|
||||
check(
|
||||
'窄屏没有抽屉式侧栏(它曾遮挡底部导航)',
|
||||
!app.includes('navOpen') && !app.includes('bg-black/40')
|
||||
);
|
||||
const ui = read('../src/stores/uiStore.ts');
|
||||
check('uiStore 不再有抽屉状态', !ui.includes('navOpen') && !ui.includes('toggleNav'));
|
||||
|
||||
// 5.2) 退出登录必须还有地方可点 —— 删抽屉时它是唯一的独有入口
|
||||
const account = read('../src/components/AccountPage.tsx');
|
||||
check(
|
||||
'退出登录已移到账号页(窄屏唯一出口)',
|
||||
account.includes('logout') && account.includes('退出登录')
|
||||
);
|
||||
|
||||
// 5.3) 触摸命中区:44x44 是移动端下限,而这些按钮视觉高度只有 15-24px。
|
||||
// .tap 用居中的透明伪元素扩大命中区,视觉尺寸不变。
|
||||
const css = read('../src/index.css');
|
||||
check(
|
||||
'.tap 提供 44px 触摸命中区且覆盖手机与竖屏平板',
|
||||
/\.tap::after/.test(css) && css.includes('min-width: 44px') &&
|
||||
css.includes('min-height: 44px') && /max-width:\s*1023px/.test(css)
|
||||
);
|
||||
// 详情页那排工具按钮是实测最小的一组(「抄送」只有 20x15)
|
||||
const viewSrc = read('../src/components/MailView.tsx');
|
||||
for (const label of ['标记已读', '对话树', '转发']) {
|
||||
const re = new RegExp('className="tap[^"]*"[^>]*>[\\s\\S]{0,120}' + label);
|
||||
check(`详情页「${label}」有 .tap 命中区`, re.test(viewSrc));
|
||||
}
|
||||
|
||||
// 5.4) 悬停才显形的次要动作在触摸设备上必须默认可见。
|
||||
// `opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远透明,
|
||||
// 却仍然接收点击 —— 一个看不见却按得动的「归档」比没有按钮更糟。
|
||||
check(
|
||||
'.reveal 只在支持悬停的设备上隐藏',
|
||||
css.includes('.reveal') && /@media\s*\(hover:\s*hover\)\s*and\s*\(pointer:\s*fine\)/.test(css)
|
||||
);
|
||||
for (const f of ['ContactPanel', 'WorkCard']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
check(
|
||||
`${f} 用 .reveal 而非裸 opacity-0 group-hover`,
|
||||
src.includes('reveal') && !src.includes('opacity-0 group-hover:opacity-100')
|
||||
);
|
||||
}
|
||||
|
||||
// 5.5) 对话树:缩进随屏宽变,且窄屏要有返回出口。
|
||||
// 固定「每级 20px、上限 8 级」在 320px 屏上把卡片压到 110px 可用宽度。
|
||||
const thread = read('../src/components/ThreadView.tsx');
|
||||
check('对话树缩进随屏宽自适应', thread.includes('useIsNarrow') && /narrow \? 10 : 20/.test(thread));
|
||||
check('对话树窄屏有返回出口', thread.includes('<BackButton'));
|
||||
|
||||
// 5.6) 每个页面级组件都要有纵向滚动容器。
|
||||
// 窄屏外壳是 `h-full flex flex-col overflow-hidden`,页面本身是
|
||||
// `flex-1 min-w-0 flex flex-col` —— 内容超过视口时**没有任何办法滚到**,
|
||||
// 超出那段直接被裁。AccountPage 曾经就缺这个:390px 下内容需 860px、
|
||||
// 容器 795px,「退出登录」按钮连同下面 65px 一起消失。
|
||||
// 判据是「存在 overflow-y-auto」,不是「当前正在滚动」——
|
||||
// 内容暂时不够高时后者为假,但页面是健康的。
|
||||
for (const f of ['AccountPage', 'AdminUsersPage', 'MailView', 'ComposePage', 'ThreadView', 'ContactPanel', 'MailList', 'PermissionList', 'CalendarView', 'CalendarEventEditor']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
check(`${f} 有纵向滚动容器`, src.includes('overflow-y-auto'));
|
||||
}
|
||||
|
||||
// 5.7) 居中的单卡片页(登录 / 初始化)在矮屏必须能滚到底。
|
||||
// `items-center` 在内容超高时让卡片上下同时溢出,而溢出到顶部那段
|
||||
// 滚不到(scrollTop 最小是 0)—— 实测 568x280 下「登录」按钮完全在
|
||||
// 视口外。改用卡片自己的 my-auto:空间不足时 auto margin 退化为 0。
|
||||
for (const f of ['LoginPage', 'SetupPage']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
check(
|
||||
`${f} 矮屏可滚且不用 items-center 居中`,
|
||||
src.includes('overflow-y-auto') && src.includes('my-auto') &&
|
||||
!/h-full[^"]*items-center/.test(src)
|
||||
);
|
||||
}
|
||||
|
||||
// 5.8) 写信页不能靠 flex 把正文压成一行。
|
||||
// 软键盘出现时可见高度骤减:顶部字段和底部附件/按钮都是固定内容,原先唯一
|
||||
// 可收缩的正文区只有 min-h-0,于是会被压到接近 0。窄屏改为整页可滚,正文
|
||||
// 保留明确的最小高度;宽屏仍使用 flex 填满剩余空间。
|
||||
check(
|
||||
'写信页窄屏整页可滚,正文有明确最小高度',
|
||||
compose.includes('overflow-y-auto lg:overflow-hidden') &&
|
||||
compose.includes('min-h-[12rem]') &&
|
||||
compose.includes('lg:flex-1')
|
||||
);
|
||||
check(
|
||||
'写信页附件与操作栏不参与正文压缩',
|
||||
compose.includes('shrink-0 px-4 md:px-6 pb-20 lg:pb-3') &&
|
||||
compose.includes('sticky bottom-0')
|
||||
);
|
||||
check('根视口使用 100dvh 跟随软键盘', css.includes('@supports (height: 100dvh)'));
|
||||
|
||||
// 6) 横向内边距在窄屏收窄(px-6 在 375px 屏上白吃 48px)
|
||||
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
|
||||
for (const f of wide) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
const bare = src.match(/className="[^"]*(?<![-:])\bpx-6\b/g) || [];
|
||||
check(`${f} 没有裸 px-6(应为 px-4 md:px-6)`, bare.length === 0, `发现 ${bare.length} 处`);
|
||||
}
|
||||
|
||||
console.log(failed === 0 ? '\n窄屏布局:全部通过' : `\n窄屏布局:${failed} 项失败`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
395
client/electron/test/theme.test.mjs
Normal file
395
client/electron/test/theme.test.mjs
Normal file
@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 深色主题的结构性检查。
|
||||
*
|
||||
* 判据全部是「源码里存在/不存在某种形态」,不需要浏览器 ——
|
||||
* 真正的视觉验收靠手工脚本(test/manual/theme-verify.mjs)。
|
||||
*
|
||||
* 这些检查存在的理由:深色模式的 bug 形态是**白底白字**,
|
||||
* 它不报错、不影响构建、只有肉眼能发现,而且往往只出现在某个不常开的页面。
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const read = p => readFileSync(join(here, p), 'utf8');
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) {
|
||||
pass++;
|
||||
console.log(` 通过 ${name}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
const css = read('../src/index.css');
|
||||
const cfg = read('../tailwind.config.js');
|
||||
const html = read('../index.html');
|
||||
|
||||
// 1) tailwind 必须走 class 策略。
|
||||
// media 策略下主题无法被人显式选择 —— 白天想开深色就做不到。
|
||||
check('darkMode 为 class 策略', /darkMode:\s*['"]class['"]/.test(cfg));
|
||||
|
||||
// 2) 颜色必须经 CSS 变量。写死十六进制的话深色模式无从切换。
|
||||
check(
|
||||
'调色板指向 CSS 变量',
|
||||
cfg.includes('rgb(var(') && cfg.includes('<alpha-value>'),
|
||||
'缺少 rgb(var(--x) / <alpha-value>) 形态'
|
||||
);
|
||||
|
||||
// 3) 变量值必须是 RGB 三元组而不是 #hex。
|
||||
// 代码里有 bg-blue-50/70 这类透明度修饰符,#hex 会生成无效 CSS,
|
||||
// 那些半透明高亮静默失效(不报错,只是不透明)。
|
||||
const varLines = css.match(/--c-[a-z]+-?\d*:\s*[^;]+;/g) || [];
|
||||
const hexVars = varLines.filter(l => l.includes('#'));
|
||||
check(
|
||||
'色板变量存 RGB 三元组而非 #hex',
|
||||
varLines.length >= 100 && hexVars.length === 0,
|
||||
hexVars.length ? `${hexVars.length} 个变量是 hex:${hexVars[0]}` : `只找到 ${varLines.length} 个变量`
|
||||
);
|
||||
|
||||
// 4) 必须有 .dark 覆盖块,且覆盖了同样多的变量。
|
||||
// 漏掉的那些会在深色下保持浅色值 —— 那正是白底白字的来源。
|
||||
const lightBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
|
||||
const darkBlock = css.slice(css.indexOf('.dark {'));
|
||||
const lightVars = new Set((lightBlock.match(/--c-[\w-]+(?=:)/g) || []));
|
||||
const darkVars = new Set((darkBlock.match(/--c-[\w-]+(?=:)/g) || []));
|
||||
const missing = [...lightVars].filter(v => !darkVars.has(v));
|
||||
check(
|
||||
'.dark 覆盖了全部色板变量',
|
||||
lightVars.size >= 15 && missing.length === 0,
|
||||
missing.length ? `深色缺 ${missing.length} 个:${missing.slice(0, 5).join(', ')}` : `浅色只有 ${lightVars.size} 个`
|
||||
);
|
||||
|
||||
// 5) 灰阶必须真的反转:深色的 white 要比 gray-900 暗。
|
||||
// 不反转的话组件里的 `bg-white text-gray-900` 在深色下依然是白底黑字。
|
||||
const lum = (block, name) => {
|
||||
const m = block.match(new RegExp(`--c-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
|
||||
if (!m) return null;
|
||||
return (Number(m[1]) + Number(m[2]) + Number(m[3])) / 3;
|
||||
};
|
||||
const darkWhite = lum(darkBlock, 'white');
|
||||
const darkG900 = lum(darkBlock, 'gray-900');
|
||||
check(
|
||||
'深色下灰阶已反转(white 比 gray-900 暗)',
|
||||
darkWhite !== null && darkG900 !== null && darkWhite < darkG900,
|
||||
`white=${darkWhite} gray-900=${darkG900}`
|
||||
);
|
||||
|
||||
// 6) 深色的页面底(gray-50)必须比卡片(white)更暗。
|
||||
// 浅色下页面底比卡片浅,深色下要反过来 —— 否则卡片陷进背景失去边界。
|
||||
const darkG50 = lum(darkBlock, 'gray-50');
|
||||
check(
|
||||
'深色下页面底比卡片更暗',
|
||||
darkG50 !== null && darkWhite !== null && darkG50 < darkWhite,
|
||||
`gray-50=${darkG50} white=${darkWhite}`
|
||||
);
|
||||
|
||||
// 7) 次要文字(gray-400/500)在深色下必须提亮。
|
||||
// 照搬浅色值只有约 2:1 对比度,远低于 WCAG AA 的 4.5:1 ——
|
||||
// 实际效果是「看得见但读不动」。
|
||||
const lightG400 = lum(lightBlock, 'gray-400');
|
||||
const darkG400 = lum(darkBlock, 'gray-400');
|
||||
check(
|
||||
'深色下次要文字未沿用浅色值',
|
||||
darkG400 !== null && lightG400 !== null && Math.abs(darkG400 - lightG400) > 5,
|
||||
`light=${lightG400} dark=${darkG400}`
|
||||
);
|
||||
|
||||
// 8) color-scheme 两处都要设。
|
||||
// 不设的话深色页面上会出现浅色滚动条与白底的 autofill 输入框。
|
||||
check(
|
||||
':root 与 .dark 都声明 color-scheme',
|
||||
/color-scheme:\s*light/.test(lightBlock) && /color-scheme:\s*dark/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 9) index.html 必须有同步内联脚本消除首帧闪屏。
|
||||
// bundle 有几百 KB,从 HTML 解析完到 React 挂载之间页面是 body 默认色 ——
|
||||
// 深色用户每次刷新都被闪一下白屏。外链或 defer 都晚于首次绘制。
|
||||
check(
|
||||
'index.html 内联防闪屏脚本',
|
||||
html.includes('agentmail.theme') &&
|
||||
html.includes('prefers-color-scheme') &&
|
||||
html.includes("classList.add('dark')") &&
|
||||
!/<script[^>]+(src=|defer)[^>]*>[\s\S]*?agentmail\.theme/.test(html),
|
||||
'缺少同步内联脚本'
|
||||
);
|
||||
|
||||
// 10) 内联脚本与 themeStore 必须用同一个 localStorage 键。
|
||||
// 不一致的后果是首帧按 A 键渲染、React 挂载后按 B 键重渲染 —— 闪一下再变回去
|
||||
const store = read('../src/stores/themeStore.ts');
|
||||
const keyInStore = store.match(/STORAGE_KEY\s*=\s*'([^']+)'/);
|
||||
check(
|
||||
'内联脚本与 themeStore 共用同一 storage 键',
|
||||
keyInStore !== null && html.includes(`'${keyInStore[1]}'`),
|
||||
keyInStore ? `store 用 ${keyInStore[1]}` : '未找到 STORAGE_KEY'
|
||||
);
|
||||
|
||||
// 11) body 必须有显式底色:移动端橡皮筋回弹露出的是 body 背景,
|
||||
// 不设的话深色下滑到边界会闪出白边。
|
||||
check(
|
||||
'body 有显式主题底色',
|
||||
/body\s*\{[^}]*background-color:\s*rgb\(var\(--c-/.test(css)
|
||||
);
|
||||
|
||||
// 12) 组件里不该残留写死的十六进制颜色。
|
||||
// 它们不经变量,深色模式下不会变 —— 而这类遗漏只有肉眼能发现。
|
||||
const compDir = join(here, '../src/components');
|
||||
const offenders = [];
|
||||
for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) {
|
||||
const src = readFileSync(join(compDir, f), 'utf8');
|
||||
// 只看 className 与 style 里的颜色;SVG 的 currentColor 不算
|
||||
const hits = (src.match(/#[0-9a-fA-F]{3,6}\b/g) || []);
|
||||
if (hits.length) offenders.push(`${f}(${hits.join(',')})`);
|
||||
}
|
||||
check(
|
||||
'组件里没有写死的十六进制颜色',
|
||||
offenders.length === 0,
|
||||
offenders.join(' ')
|
||||
);
|
||||
|
||||
// 13) 主题三态:system 不能被当成 light 的别名。
|
||||
// 只给开关的话,白天设浅色之后晚上系统切深色应用不会跟着变
|
||||
check(
|
||||
'ThemePref 是三态且含 system',
|
||||
/'light'\s*\|\s*'dark'\s*\|\s*'system'/.test(store) && store.includes("=== 'system'")
|
||||
);
|
||||
|
||||
// 14) 只有 pref 为 system 时才跟随系统变化。
|
||||
// 显式选了 light/dark 的人不该因为日落被切换主题
|
||||
check(
|
||||
'仅 system 偏好跟随系统变化',
|
||||
/if\s*\(pref === 'system'\)/.test(store)
|
||||
);
|
||||
|
||||
// 15) text-white 必须与 bg-white 用不同的变量。
|
||||
// `white` 服务两种冲突用途:卡片表面(深色下变暗)与彩色按钮上的文字
|
||||
// (深色下必须保持浅色)。共用一个变量时后者跟着变暗 —— 实测激活导航项的
|
||||
// 「收件」在 bg-chrome-700 上只剩 1.34:1,几乎看不见。
|
||||
check(
|
||||
'textColor.white 指向独立变量(不跟 bg-white 一起反转)',
|
||||
/textColor:\s*\{[^}]*white:\s*withAlpha\('--c-on-accent'\)/.test(cfg) &&
|
||||
/--c-on-accent:/.test(lightBlock) &&
|
||||
/--c-on-accent:/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 16) --c-on-accent 在深色下必须仍然是浅色。
|
||||
// 它变暗就是上一条描述的那个 bug。
|
||||
const onAccentDark = lum(darkBlock, 'on-accent');
|
||||
check(
|
||||
'深色下 on-accent 仍是浅色',
|
||||
onAccentDark !== null && onAccentDark > 200,
|
||||
`on-accent 亮度 ${onAccentDark}`
|
||||
);
|
||||
|
||||
// 17) 每个被 Tailwind 实际使用的 CSS 变量都必须在 index.css 有定义。
|
||||
//
|
||||
// 这一条守的是本项目最贵的一次视觉 bug:tailwind.config.js 里 `colors`
|
||||
// 同时写了固定 hex 与 accent() 两份 red/green/amber/orange/yellow ——
|
||||
// JS 对象字面量重复键**后者胜出**(不报错、不警告),而 index.css 里当时
|
||||
// 没有对应的变量。`rgb(var(--c-red-600) / 1)` 里变量未定义会让整条
|
||||
// background-color 声明失效,于是 bg-red-600 退回透明、text-white 的白字
|
||||
// 落在白卡片上:**按钮看不见但点得动**。32 个变量全部缺失,
|
||||
// 波及所有 red/green/amber/orange/yellow 的地方。
|
||||
//
|
||||
// 判据必须走 resolveConfig 而不是正则扫配置文本:真正出问题的那批变量名
|
||||
// 是 accent('red') 这类**模板拼出来的**,配置源码里根本没有 `--c-red-600`
|
||||
// 这个字面量 —— 扫文本会漏掉正是要防的那一类。
|
||||
const resolveConfig = (await import('tailwindcss/resolveConfig.js')).default;
|
||||
const resolved = resolveConfig((await import('../tailwind.config.js')).default);
|
||||
|
||||
const declared = new Set([...css.matchAll(/--[cs]-[a-z0-9-]+(?=\s*:)/g)].map(m => m[0]));
|
||||
const usedVars = new Set();
|
||||
const walk = (v) => {
|
||||
if (typeof v === 'string') {
|
||||
for (const m of v.matchAll(/var\((--[a-z0-9-]+)/g)) usedVars.add(m[1]);
|
||||
} else if (v && typeof v === 'object') {
|
||||
for (const k of Object.keys(v)) walk(v[k]);
|
||||
}
|
||||
};
|
||||
// 只看颜色相关的 theme 段:其余(spacing/fontSize/...)不走变量
|
||||
for (const key of ['colors', 'textColor', 'backgroundColor', 'borderColor', 'ringColor', 'divideColor']) {
|
||||
walk(resolved.theme[key]);
|
||||
}
|
||||
const undef = [...usedVars].filter(v => !declared.has(v));
|
||||
check(
|
||||
'Tailwind 实际使用的每个变量都在 index.css 有定义',
|
||||
usedVars.size >= 100 && undef.length === 0,
|
||||
undef.length
|
||||
? `${undef.length} 个未定义:${undef.slice(0, 6).join(', ')}`
|
||||
: `只解析出 ${usedVars.size} 个变量引用`
|
||||
);
|
||||
|
||||
// 18) colors 里每个颜色名只能定义一次。
|
||||
// 重复键静默生效,是上一条那个 bug 的**成因**:读代码的人看到固定 hex
|
||||
// 以为在用它,实际生效的是下面那份 accent()。
|
||||
const colorsBlock = cfg.slice(cfg.indexOf('colors: {'));
|
||||
const dupNames = [];
|
||||
for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow', 'gray', 'chrome']) {
|
||||
const n = (colorsBlock.match(new RegExp(`^\\s{8}${name}:`, 'gm')) || []).length;
|
||||
if (n > 1) dupNames.push(`${name}×${n}`);
|
||||
}
|
||||
check('colors 里没有重复的颜色名', dupNames.length === 0, dupNames.join(' '));
|
||||
|
||||
// 19) 强调色的**表面段**(50–300)深色下必须变暗。
|
||||
// 照搬浅色值的话 red-50 (#fef2f2) 在深色页面上是一块近白亮斑 ——
|
||||
// 那是错误提示条的底,结果比正文还抢眼,上面的红字反而读不动。
|
||||
const surfaceOffenders = [];
|
||||
for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow']) {
|
||||
for (const shade of [50, 100, 200, 300]) {
|
||||
const l = lum(lightBlock, `${name}-${shade}`);
|
||||
const d = lum(darkBlock, `${name}-${shade}`);
|
||||
if (l === null || d === null) { surfaceOffenders.push(`${name}-${shade}:缺失`); continue; }
|
||||
if (d >= l) surfaceOffenders.push(`${name}-${shade}(${d}≥${l})`);
|
||||
}
|
||||
}
|
||||
check(
|
||||
'强调色表面段在深色下变暗',
|
||||
surfaceOffenders.length === 0,
|
||||
surfaceOffenders.slice(0, 6).join(' ')
|
||||
);
|
||||
|
||||
// 20) 强调色的**前景段**(400–900)在深色卡片上必须达到 WCAG AA 4.5:1。
|
||||
// 照搬浅色值时 red-700 只有 2.67:1、amber-900 只有 1.90:1 ——
|
||||
// 「看得见但读不动」跟看不见是同一类 bug。
|
||||
const rgbOf = (block, name) => {
|
||||
const m = block.match(new RegExp(`--c-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
const srgb = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||
const relLum = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
||||
const contrast = (a, b) => {
|
||||
const l1 = relLum(a), l2 = relLum(b);
|
||||
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const darkCard = rgbOf(darkBlock, 'white');
|
||||
const lowContrast = [];
|
||||
for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow']) {
|
||||
for (const shade of [400, 500, 600, 700, 800, 900]) {
|
||||
const fg = rgbOf(darkBlock, `${name}-${shade}`);
|
||||
if (!fg) { lowContrast.push(`${name}-${shade}:缺失`); continue; }
|
||||
const r = contrast(fg, darkCard);
|
||||
if (r < 4.5) lowContrast.push(`${name}-${shade}:${r.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
check(
|
||||
'强调色前景段在深色卡片上达到 4.5:1',
|
||||
darkCard !== null && lowContrast.length === 0,
|
||||
lowContrast.slice(0, 6).join(' ')
|
||||
);
|
||||
|
||||
// 21) 实心按钮底走独立的 --s-* 且**两种模式同值**。
|
||||
//
|
||||
// accent 的 400–900 在深色下被提亮(为了 text-red-600 读得动),
|
||||
// 而 `bg-red-600 text-white` 的白字落在那个浅红上只有 1.6:1。
|
||||
// 一个名字服务两种语义必然坏掉一头 —— 与 text-white/bg-white 那次同理。
|
||||
check(
|
||||
'实心按钮底走 --s-* 并只覆盖 backgroundColor',
|
||||
/const solid = \(name\) =>/.test(cfg) &&
|
||||
/backgroundColor:\s*\{[^}]*red:\s*solid\('red'\)/s.test(cfg) &&
|
||||
/--s-red-600:/.test(css)
|
||||
);
|
||||
|
||||
// 22) --s-* 不能出现在 .dark 里:它必须两种模式同值。
|
||||
// 在 .dark 覆盖等于把「危险操作是红的」这件事也一起反转了。
|
||||
check(
|
||||
'--s-* 未被 .dark 覆盖(实心底不随主题变)',
|
||||
!/--s-[a-z]+-\d+:/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 23) 白字落在实心按钮底上必须达到 4.5:1(两种模式同一组值,只需算一次)。
|
||||
const solidBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
|
||||
const sRgb = name => {
|
||||
const m = solidBlock.match(new RegExp(`--s-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
// 代码里真正出现过的「实心底 + 白字」组合
|
||||
const solidPairs = [
|
||||
['blue-600'], ['blue-700'],
|
||||
['red-600'], ['red-700'],
|
||||
['green-700'], ['orange-700']
|
||||
];
|
||||
const onAccentLight = rgbOf(lightBlock, 'on-accent') ||
|
||||
(lightBlock.match(/--c-on-accent:\s*(\d+)\s+(\d+)\s+(\d+)/) || []).slice(1).map(Number);
|
||||
const weakButtons = [];
|
||||
for (const [name] of solidPairs) {
|
||||
const bg = sRgb(name);
|
||||
if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
|
||||
// 这些按钮文字只有 9–14px,按普通文字执行 WCAG AA 4.5:1。
|
||||
const r = contrast(onAccentLight, bg);
|
||||
if (r < 4.5) weakButtons.push(`${name}:${r.toFixed(2)}`);
|
||||
}
|
||||
check(
|
||||
'白字在实心按钮底上达到 4.5:1',
|
||||
weakButtons.length === 0,
|
||||
weakButtons.join(' ')
|
||||
);
|
||||
|
||||
// 24) 应用框架(侧栏 / 底部导航)必须有独立色阶。
|
||||
// 它在浅色模式下本来就是深色的 —— 并入反转的 gray 之后深色模式下会变成
|
||||
// 近白色,比内容区还亮,整个层次翻过来(实测 rgb(243,245,248))。
|
||||
check(
|
||||
'框架有独立的 chrome 色阶',
|
||||
/chrome:\s*\{/.test(cfg) &&
|
||||
/--c-chrome-900:/.test(lightBlock) &&
|
||||
/--c-chrome-900:/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 25) 深色下框架必须比内容区更沉(保持浅色下就有的层次关系)。
|
||||
const chromeDark = lum(darkBlock, 'chrome-900');
|
||||
check(
|
||||
'深色下框架比内容区更暗',
|
||||
chromeDark !== null && darkG50 !== null && chromeDark < darkG50,
|
||||
`chrome-900=${chromeDark} gray-50=${darkG50}`
|
||||
);
|
||||
|
||||
// 26) 侧栏与底部导航里不该残留 slate-*(那条色阶指向反转的 gray)。
|
||||
const chromeFiles = ['Sidebar', 'NarrowNav'];
|
||||
const slateLeft = [];
|
||||
for (const f of chromeFiles) {
|
||||
const src = readFileSync(join(here, `../src/components/${f}.tsx`), 'utf8');
|
||||
const hits = src.match(/(?:bg|text|border|hover:bg|hover:text|active:bg|ring)-slate-\d+/g) || [];
|
||||
if (hits.length) slateLeft.push(`${f}: ${hits.join(',')}`);
|
||||
}
|
||||
check('框架组件已全部改用 chrome 色阶', slateLeft.length === 0, slateLeft.join(' | '));
|
||||
|
||||
// 27) 可逆灰阶不能作为白字实心按钮底。gray-700/800/900 在深色模式下会
|
||||
// 被反转成近白色,`bg-gray-900 text-white` 因而只剩约 1:1。
|
||||
const componentSources = readdirSync(compDir)
|
||||
.filter(x => x.endsWith('.tsx'))
|
||||
.map(f => [f, readFileSync(join(compDir, f), 'utf8')]);
|
||||
const graySolid = componentSources.flatMap(([f, src]) =>
|
||||
(src.match(/(?:bg-gray-(?:700|800|900)[^'"\n]*text-white|text-white[^'"\n]*bg-gray-(?:700|800|900))/g) || [])
|
||||
.map(hit => `${f}:${hit}`)
|
||||
);
|
||||
check('白字实心控件不使用可逆 gray 底色', graySolid.length === 0, graySolid.slice(0, 4).join(' | '));
|
||||
|
||||
// 28) 未映射色族会绕过 CSS 变量主题,浅色值会原样落进深色页面。
|
||||
const unmapped = componentSources.flatMap(([f, src]) =>
|
||||
(src.match(/(?:bg|text|border)-(?:emerald|purple)-\d+/g) || []).map(hit => `${f}:${hit}`)
|
||||
);
|
||||
check('组件未使用未映射的 emerald/purple 色族', unmapped.length === 0, unmapped.slice(0, 6).join(' | '));
|
||||
|
||||
// 29) 本地 markdown 层替代未安装 typography 插件时无效的 prose 类。
|
||||
const inertProse = componentSources.flatMap(([f, src]) =>
|
||||
(src.match(/\bprose(?:-sm)?\b/g) || []).map(hit => `${f}:${hit}`)
|
||||
);
|
||||
check('Markdown 内容使用本地 .markdown 排版层', inertProse.length === 0, inertProse.slice(0, 4).join(' | '));
|
||||
|
||||
// 30) gray-400 承担 9–12px 元数据与 placeholder,白卡片上也必须达到 4.5:1。
|
||||
const lightCard = rgbOf(lightBlock, 'white');
|
||||
const lightSecondary = rgbOf(lightBlock, 'gray-400');
|
||||
const secondaryContrast = lightCard && lightSecondary ? contrast(lightCard, lightSecondary) : 0;
|
||||
check(
|
||||
'浅色 gray-400 在白卡片上达到 4.5:1',
|
||||
secondaryContrast >= 4.5,
|
||||
secondaryContrast.toFixed(2)
|
||||
);
|
||||
|
||||
console.log(`\n主题:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
Reference in New Issue
Block a user