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
264 lines
8.5 KiB
TypeScript
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()
|
|
})
|
|
})
|