Files
MailUI4Agents/client/electron/test/components/LoginPage.test.tsx
JianFeeeee 8b2206ed53 fix(electron): Phase 3 验收抓到的两个静默缺陷 —— 白屏与登录
Phase 3(写信 + 附件 + 权限面板)的验收脚本第一次跑就把这两件事翻出来了,
两个都**表现正常**:进程活着、窗口标题对、接口能通,只有结果不对。

## 1. 打包后的应用是白屏(vite 的 base 缺省值)

`vite.config.ts` 没设 `base`,Vite 按默认的 `/` 生成 `src="/assets/index-xxx.js"`。
同一份 dist 有两个宿主:网关在 `/` 下伺服它(Web 正常),Electron 用 `loadFile()`
从 **file:///…/dist/index.html** 加载它 —— 绝对路径在那儿解析成
`file:///assets/index-xxx.js`(不存在),**JS 根本没加载**。

现场:`#root` 里一个子节点都没有。没有报错对话框,控制台里只有一条不起眼的
资源加载失败。而当时所有既有检查都是绿的:`npm run build` 成功、deb 元数据检查、
asar 内容清点(**它们只看文件在不在,不看文件引用什么**)。

修法:`base: './'` —— 两边都对(Web 在 /index.html 里 `./assets/x.js` → `/assets/x.js`;
Electron 在 dist/index.html 里 → `dist/assets/x.js`)。

## 2. 桌面壳用账号密码登录是断的,而且静默失败

账号密码登录靠 `SameSite=Lax` 的会话 Cookie,而桌面壳的页面是 `file://`
(**不透明源**)—— Chromium 按第三方上下文处理它,Cookie **不予存储**。

实测现场:`POST /auth/login` 返 **200**、响应体能读出用户名,但 `document.cookie`
是空的,紧接着的 `/auth/me` 返 **401**;界面停在登录页,看起来像「密码错了」,
而同样的账号密码用 curl 登录是成功的。所以这不是凭据问题。

修法:桌面壳里**不再给账号密码表**(一个必然失败的按钮比没有更糟),改成粘贴
**用户密钥**(`Authorization: Bearer`,桌面端本来就该这么用):
- preload 显式声明 `__AGENTMAIL_SHELL__ = 'desktop'`(宿主契约,而不是让渲染层
  sniff 协议;顺带让 jsdom 里可测 —— 那里的 `location.protocol` 不可重写)
- 新增 `authStore.loginWithKey`:成功后才留下令牌,失败**还原**(否则之后每个请求
  都会带上这个坏 key 并 401,而人看到的是「重输一次也不行」)
- 顺手修了 label 与 input 没有关联(`htmlFor`/`id`)—— 无障碍缺陷,也让测试能按标签查

## 验收

- 结构性守卫进 `npm test`(`test/packaging.test.mjs`,不需要浏览器):base 必须是
  相对路径、产物里不能有绝对资源引用、**安装包里的 dist 与当前构建一致**
  (前端改了没重打包时,装上去的人看到的是旧界面,两边不一致却谁都不报错)。
  判据自检过:把 base 改回 `/` 或把产物改回绝对路径,各自都能让对应那条变红。
- 组件测试 6 条(两种壳的形态、密钥成功/失败、空密钥不可提交)。
- `test/manual/desktop-phase3-verify.mjs`:真起打包好的应用(xvfb + CDP),
  一条贯穿的链 —— 用桌面 UI 写信带附件 → 外部核验信与附件真到了网关 →
  这封信触发 zcode 的真实授权请求 → 在桌面**授权面板**里点同意 →
  外部核验 **Agent 真的执行了**(标记文件出现)。第二次跑 14/14 全绿。
- 客户端全量 222/222;网关换新产物后 Web 依旧正常(相对路径在 `/` 下同样成立,
  实测渲染出收件箱、无控制台错误),并真发一封邮件确认回信到达。

## 判据自己的错(记一笔)

第一次跑时「附件真的挂在信上」报红,而库里那 41 字节的附件**明明挂在信上** ——
我把端点写成了 `/me/mail/{id}`(不存在,404),正确是 `/mail/{id}`。
判据用错端点时以「附件是空的」现形,看起来像功能 bug。

另:`pkill -f 'agentmail-web'` 会把**自己这条命令**也杀掉(命令行里含同样的字符串),
表现是「脚本没有任何输出、退出码 143」。改用端口定位(`ss -tlnp | grep :9223`)。
2026-09-12 20:25:00 +08:00

122 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import LoginPage from '../../src/components/LoginPage';
import * as api from '../../src/api/client';
import { useAuthStore } from '../../src/stores/authStore';
/**
* 登录页的两种形态。
*
* # 这一条不是「多测一个分支」,而是一次真实故障的回归
*
* 打包后的桌面应用Electron 用 `loadFile()` 从 `file://` 加载)**白屏之外还有第二个坑**
* 登录页照常出现、账号密码填对、`POST /auth/login` 也返回 200
* 但**页面停在登录页不动**,并且接口开始报 401。
*
* 根因是会话 Cookie 存不下来:网关下发 `SameSite=Lax`,而 `file://` 是不透明源,
* Chromium 按第三方上下文处理它、不予存储。实测现场是
* `document.cookie === ''` 且紧接着的 `/auth/me` 返回 401 ——
* 用同样的账号密码从外部 curl 登录却是成功的,所以这不是凭据问题。
*
* 因此桌面壳里**不该出现**一个注定失败的账号密码表单:那会让人以为密码打错了。
* 它应当换成用户密钥(`Authorization: Bearer`)—— 桌面端本来就该用这种方式。
*/
/**
* 切换「壳」。
*
* 用的是宿主显式声明的 `__AGENTMAIL_SHELL__`,而不是去改 `location.protocol` ——
* jsdom 里那个属性**不可重定义**`TypeError: Cannot redefine property: protocol`
* 而更根本的理由是:壳身份本来就该是宿主声明的契约,不该靠渲染层 sniff 协议。
*/
function setShell(shell: 'desktop' | 'browser') {
if (shell === 'desktop') window.__AGENTMAIL_SHELL__ = 'desktop';
else delete window.__AGENTMAIL_SHELL__;
}
beforeEach(() => {
useAuthStore.setState({
phase: 'anonymous',
user: null,
error: null,
retryAfter: null,
submitting: false
});
api.setToken(null);
});
afterEach(() => {
vi.restoreAllMocks();
setShell('browser');
api.setToken(null);
});
describe('登录页(浏览器外壳)', () => {
it('给账号密码,不给密钥输入', () => {
setShell('browser');
render(<LoginPage />);
expect(screen.getByLabelText('用户名')).toBeInTheDocument();
expect(screen.getByLabelText('密码')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument();
expect(screen.queryByLabelText('用户密钥')).not.toBeInTheDocument();
});
it('用户名/密码为空时不能提交', async () => {
setShell('browser');
render(<LoginPage />);
expect(screen.getByRole('button', { name: '登录' })).toBeDisabled();
});
});
describe('登录页桌面外壳file://', () => {
it('★ 不给账号密码表单,给用户密钥', () => {
setShell('desktop');
render(<LoginPage />);
// 反向对照:账号密码那两个字段必须**不在**
expect(screen.queryByLabelText('密码')).not.toBeInTheDocument();
expect(screen.queryByLabelText('用户名')).not.toBeInTheDocument();
expect(screen.getByLabelText('用户密钥')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '进入' })).toBeInTheDocument();
// 必须把原因说出来,否则用户会以为自己装错了版本
expect(screen.getByText(/浏览器.*Cookie|Cookie.*不透明源|会话 Cookie/)).toBeInTheDocument();
});
it('★ 密钥可用时进入应用(走 Bearer不靠 Cookie', async () => {
setShell('desktop');
const me = vi.spyOn(api, 'me').mockResolvedValue({
user: { user_id: 'u1', username: 'gui-lab', display_name: 'GUI', role: 'user' } as never
});
render(<LoginPage />);
await userEvent.type(screen.getByLabelText('用户密钥'), 'k'.repeat(32));
await userEvent.click(screen.getByRole('button', { name: '进入' }));
await waitFor(() => expect(useAuthStore.getState().phase).toBe('authenticated'));
expect(me).toHaveBeenCalled();
// 令牌真的进了 api 层(之后的每个请求都靠它)
expect(api.getToken()).toBe('k'.repeat(32));
});
it('★ 密钥不可用时:报错、清空输入、**不把坏密钥留在内存里**', async () => {
setShell('desktop');
vi.spyOn(api, 'me').mockRejectedValue(new Error('Unauthorized'));
render(<LoginPage />);
const input = screen.getByLabelText('用户密钥');
await userEvent.type(input, 'bad-key');
await userEvent.click(screen.getByRole('button', { name: '进入' }));
await waitFor(() => expect(useAuthStore.getState().error).toMatch(/密钥不可用/));
// 关键:没还原的话,之后**每一次**请求都会带上这个坏 key 并 401
// 而人看到的却是「重输一次也还是不行」。
expect(api.getToken()).toBeNull();
expect((input as HTMLInputElement).value).toBe('');
expect(useAuthStore.getState().phase).toBe('anonymous');
});
it('密钥为空时不能提交(不要发一次注定 401 的请求)', () => {
setShell('desktop');
render(<LoginPage />);
expect(screen.getByRole('button', { name: '进入' })).toBeDisabled();
});
});