Files
hermes-agent/apps/desktop/src/app/me/me-page.test.tsx
T
m4 fb13457f6f
CI / Supply-chain scan (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
feat: consolidate desktop and provider updates
2026-09-27 17:37:19 +08:00

264 lines
8.5 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { UserAccountCard } from '@/app/chat/sidebar/user-account-card'
import { MePage } from '@/app/me'
import { $machineBinding, $userAccount } from '@/store/user-account'
const PROFILE = {
email: 'u@example.com',
username: 'u-hermes',
nickname: '小赫',
phone: '13800000000',
planId: 'pro',
periodEnd: '2026-10-01',
dataAsOf: 't0'
}
function signedIn(overrides: Record<string, unknown> = {}) {
$userAccount.set({
status: 'signed_in',
site: 'https://r',
offline: false,
profile: PROFILE,
profileAsOf: null,
...overrides
})
}
function stubBridge(overrides: Record<string, unknown> = {}) {
const api = {
login: vi.fn(),
status: vi.fn(async () => ({ loggedIn: true, site: 'https://r', offline: false, profile: PROFILE })),
me: vi.fn(async () => ({ profile: PROFILE })),
updateProfile: vi.fn(async (payload: any): Promise<any> => ({ ok: true, profile: { ...PROFILE, ...payload } })),
logout: vi.fn(async () => ({ ok: true })),
registerStart: vi.fn(),
registerResend: vi.fn(),
resetRequest: vi.fn(),
resetConfirm: vi.fn(),
bindingStatus: vi.fn(async () => ({ bound: false })),
bindMachine: vi.fn(async () => ({ ok: true, state: 'bound' })),
unbindMachine: vi.fn(async () => ({ ok: true })),
...overrides
}
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { userAccount: api } })
return api
}
beforeEach(() => {
$userAccount.set({ status: 'unknown' })
$machineBinding.set(null)
})
afterEach(() => {
cleanup()
Reflect.deleteProperty(window, 'hermesDesktop')
})
describe('MePage', () => {
it('signed_in:渲染 U-4 展示名、邮箱与套餐信息', () => {
stubBridge()
signedIn()
render(<MePage />)
expect(screen.getByText('小赫')).toBeTruthy()
expect(screen.getAllByText('u@example.com').length).toBeGreaterThan(0)
expect(screen.getByText('pro')).toBeTruthy()
expect(screen.getByText('2026-10-01')).toBeTruthy()
})
it('离线:amber 横幅注明缓存数据时间', () => {
stubBridge()
signedIn({ offline: true, profileAsOf: '2026-09-19T08:00:00Z' })
render(<MePage />)
expect(screen.getByText(/2026-09-19T08:00:00Z/)).toBeTruthy()
})
it('只提交改动字段;成功后草稿归零并显示已保存', async () => {
const api = stubBridge()
signedIn()
render(<MePage />)
fireEvent.change(screen.getByDisplayValue('小赫'), { target: { value: '新小赫' } })
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(api.updateProfile).toHaveBeenCalledWith({ nickname: '新小赫' }))
await screen.findByText('Saved.')
// 快照回写:展示名与表单值都更新
expect(($userAccount.get() as any).profile.nickname).toBe('新小赫')
})
it('未改动时保存按钮禁用;服务端拒绝按码上文案', async () => {
const api = stubBridge()
api.updateProfile.mockResolvedValue({ ok: false, code: 'profile_self_service_disabled' })
signedIn()
render(<MePage />)
expect((screen.getByText('Save') as HTMLButtonElement).disabled).toBe(true)
fireEvent.change(screen.getByDisplayValue('13800000000'), { target: { value: '13900000000' } })
fireEvent.click(screen.getByText('Save'))
await screen.findByText('Profile editing is disabled on this server.')
})
it('用户名不可改:只读展示,不进表单也无 patch', () => {
const api = stubBridge()
signedIn()
render(<MePage />)
// username 以只读行展示,而非可编辑输入框。
expect(screen.queryByDisplayValue('u-hermes')).toBeNull()
expect(screen.getByText('u-hermes')).toBeTruthy()
expect(api.updateProfile).not.toHaveBeenCalled()
})
it('登出:走 bridge 并落 signed_out(登出≠解绑,门自落)', async () => {
const api = stubBridge()
signedIn()
render(<MePage />)
fireEvent.click(screen.getByText('Sign out'))
await waitFor(() => expect($userAccount.get().status).toBe('signed_out'))
expect(api.logout).toHaveBeenCalled()
})
it('无会话/无资料:空态而不是崩溃', () => {
stubBridge()
$userAccount.set({ status: 'signed_out' })
render(<MePage />)
expect(screen.getByText('Account details are unavailable.')).toBeTruthy()
})
it('未绑定:展示绑定按钮;绑定成功后重查状态并显示 site + installationId', async () => {
const api = stubBridge({
bindingStatus: vi
.fn()
.mockResolvedValueOnce({ bound: false })
.mockResolvedValue({ bound: true, site: 'https://r', installationId: 'inst-1' })
})
signedIn()
render(<MePage />)
const bindButton = await screen.findByText('Bind this machine')
fireEvent.click(bindButton)
await waitFor(() => expect(api.bindMachine).toHaveBeenCalled())
await screen.findByText('https://r')
expect(screen.getByText('inst-1')).toBeTruthy()
})
it('binding_pending:确认码与有效期随状态可查;重试走同一通路', async () => {
const api = stubBridge({
bindingStatus: vi.fn(async () => ({
bound: false,
machineId: null,
pending: { code: 'MR-7K2P-Q9', expiresAt: '2026-09-19T12:00:00Z' },
bindError: null
}))
})
signedIn()
render(<MePage />)
await screen.findByText(/MR-7K2P-Q9/)
expect(screen.getByText(/2026-09-19T12:00:00Z/)).toBeTruthy()
fireEvent.click(screen.getByText('Retry binding'))
await waitFor(() => expect(api.bindMachine).toHaveBeenCalled())
})
it('绑定失败按码上文案(plugin_unreachable),并可重试', async () => {
const api = stubBridge({
bindingStatus: vi
.fn()
.mockResolvedValueOnce({ bound: false, machineId: null, pending: null, bindError: null })
.mockResolvedValue({ bound: false, machineId: null, pending: null, bindError: 'plugin_unreachable' }),
bindMachine: vi.fn(async () => ({ ok: false, code: 'plugin_unreachable' }))
})
signedIn()
render(<MePage />)
fireEvent.click(await screen.findByText('Bind this machine'))
await screen.findByText('The local machine channel is unavailable. Restart Hermes and try again.')
expect(api.bindMachine).toHaveBeenCalled()
})
it('机器码随绑定状态展示(已绑定与未绑定同口径)', async () => {
stubBridge({
bindingStatus: vi.fn(async () => ({
bound: true,
site: 'https://r',
installationId: 'inst-1',
machineId: 'MR-ABCD-1234'
}))
})
signedIn()
render(<MePage />)
await screen.findByText('inst-1')
expect(screen.getByText('Machine ID')).toBeTruthy()
expect(screen.getByText('MR-ABCD-1234')).toBeTruthy()
})
it('已绑定:显示 site + installationId;解绑走破坏性确认框,成功落 signed_out', async () => {
const api = stubBridge({
bindingStatus: vi.fn(async () => ({ bound: true, site: 'https://r', installationId: 'inst-1' }))
})
signedIn()
render(<MePage />)
await screen.findByText('inst-1')
fireEvent.click(screen.getByText('Unbind…'))
fireEvent.click(await screen.findByText('Unbind and wipe'))
await waitFor(() => expect(api.unbindMachine).toHaveBeenCalled())
await waitFor(() => expect($userAccount.get().status).toBe('signed_out'))
})
})
describe('UserAccountCard', () => {
it('signed_in:只渲染展示名;未登录不渲染', () => {
stubBridge()
signedIn()
const { unmount } = render(
<MemoryRouter>
<UserAccountCard />
</MemoryRouter>
)
expect(screen.getByText('小赫')).toBeTruthy()
unmount()
$userAccount.set({ status: 'signed_out' })
render(
<MemoryRouter>
<UserAccountCard />
</MemoryRouter>
)
expect(screen.queryByText('小赫')).toBeNull()
})
it('离线:名旁标 amber 圆点', () => {
stubBridge()
signedIn({ offline: true })
render(
<MemoryRouter>
<UserAccountCard />
</MemoryRouter>
)
expect(screen.getByLabelText('Offline — showing cached data')).toBeTruthy()
})
it('绑定待处理(失败/pending):名旁标 amber 圆点', () => {
stubBridge()
signedIn()
$machineBinding.set({ bound: false, machineId: null, pending: null, bindError: 'plugin_unreachable' })
render(
<MemoryRouter>
<UserAccountCard />
</MemoryRouter>
)
expect(screen.getByLabelText('Machine binding needs attention — open to review')).toBeTruthy()
})
})