@ploykit/ui 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/dist/AppProviders.js +2 -2
  2. package/dist/components/ConfirmDialog.js +1 -1
  3. package/dist/components/CronInput.js +3 -3
  4. package/dist/components/DataTable.js +4 -4
  5. package/dist/components/FormField.js +1 -1
  6. package/dist/components/ImpersonationBanner.js +1 -1
  7. package/dist/components/NotificationBell.js +1 -1
  8. package/dist/components/OnboardingChecklist.js +3 -3
  9. package/dist/components/Page.js +2 -2
  10. package/dist/components/ReauthDialog.js +4 -4
  11. package/dist/components/SecretModal.js +2 -2
  12. package/dist/components/SettingsCard.js +2 -2
  13. package/dist/components/SiteBanner.js +1 -1
  14. package/dist/components/SiteBannerLayer.js +1 -1
  15. package/dist/components/StatusBadge.js +1 -1
  16. package/dist/components/UserMenu.js +3 -3
  17. package/dist/components/WorkspaceSwitcher.js +7 -7
  18. package/dist/components/ui/alert-dialog.js +2 -2
  19. package/dist/components/ui/avatar.js +1 -1
  20. package/dist/components/ui/badge.js +1 -1
  21. package/dist/components/ui/breadcrumb.js +1 -1
  22. package/dist/components/ui/button.js +1 -1
  23. package/dist/components/ui/card.js +1 -1
  24. package/dist/components/ui/checkbox.js +1 -1
  25. package/dist/components/ui/dialog.js +2 -2
  26. package/dist/components/ui/dropdown-menu.js +1 -1
  27. package/dist/components/ui/empty.js +1 -1
  28. package/dist/components/ui/field.js +3 -3
  29. package/dist/components/ui/input.js +1 -1
  30. package/dist/components/ui/label.js +1 -1
  31. package/dist/components/ui/popover.js +1 -1
  32. package/dist/components/ui/progress.js +1 -1
  33. package/dist/components/ui/select.js +1 -1
  34. package/dist/components/ui/separator.js +1 -1
  35. package/dist/components/ui/sheet.js +2 -2
  36. package/dist/components/ui/sidebar.js +8 -8
  37. package/dist/components/ui/skeleton.js +1 -1
  38. package/dist/components/ui/switch.js +1 -1
  39. package/dist/components/ui/tabs.js +1 -1
  40. package/dist/components/ui/textarea.js +1 -1
  41. package/dist/components/ui/tooltip.js +1 -1
  42. package/dist/index.js +78 -78
  43. package/dist/layouts/AppShell.js +4 -4
  44. package/dist/layouts/SettingsShell.js +4 -4
  45. package/dist/pages/LoginPage.js +5 -5
  46. package/dist/pages/account/NotificationPreferencesPage.js +3 -3
  47. package/dist/pages/account/ProfileSettingsPage.js +9 -9
  48. package/dist/pages/account/SecuritySettingsPage.js +13 -13
  49. package/dist/pages/account/TokensPage.js +12 -12
  50. package/dist/pages/admin/AdminSettingsPage.js +11 -11
  51. package/dist/pages/admin/AdminShell.js +3 -3
  52. package/dist/pages/admin/AuditPage.js +8 -8
  53. package/dist/pages/admin/OrdersPage.js +12 -12
  54. package/dist/pages/admin/OverviewPage.js +13 -13
  55. package/dist/pages/admin/SSOPage.js +6 -6
  56. package/dist/pages/admin/UserDetailPage.js +13 -13
  57. package/dist/pages/admin/UsersPage.js +13 -13
  58. package/dist/pages/admin/WebhookDeliveriesPage.js +9 -9
  59. package/dist/pages/admin/WorkspaceDetailPage.js +14 -14
  60. package/dist/pages/admin/WorkspacesPage.js +9 -9
  61. package/dist/pages/admin/shared.js +1 -1
  62. package/dist/pages/auth/ForgotPasswordPage.js +4 -4
  63. package/dist/pages/auth/InviteAcceptPage.js +6 -6
  64. package/dist/pages/auth/RegisterPage.js +7 -7
  65. package/dist/pages/auth/ResetPasswordPage.js +5 -5
  66. package/dist/pages/auth/VerifyEmailPage.js +3 -3
  67. package/dist/pages/workspace/BillingPage.js +11 -11
  68. package/dist/pages/workspace/BillingSuccessPage.js +2 -2
  69. package/dist/pages/workspace/MembersPage.js +15 -15
  70. package/dist/pages/workspace/RolePermissionsPage.js +9 -9
  71. package/dist/pages/workspace/SchedulesPage.js +12 -12
  72. package/dist/pages/workspace/UsagePage.js +6 -6
  73. package/dist/pages/workspace/WebhooksPage.js +16 -16
  74. package/dist/pages/workspace/WorkspaceAuditPage.js +7 -7
  75. package/dist/pages/workspace/WorkspaceGeneralPage.js +9 -9
  76. package/package.json +6 -5
  77. package/src/AppProviders.tsx +29 -0
  78. package/src/components/ConfirmDialog.tsx +77 -0
  79. package/src/components/CronInput.tsx +195 -0
  80. package/src/components/DataTable.tsx +152 -0
  81. package/src/components/ErrorBoundary.tsx +46 -0
  82. package/src/components/FormField.tsx +56 -0
  83. package/src/components/ImpersonationBanner.tsx +23 -0
  84. package/src/components/NotificationBell.tsx +135 -0
  85. package/src/components/OnboardingChecklist.tsx +114 -0
  86. package/src/components/Page.tsx +67 -0
  87. package/src/components/ReauthDialog.tsx +94 -0
  88. package/src/components/SecretModal.tsx +147 -0
  89. package/src/components/SettingsCard.tsx +39 -0
  90. package/src/components/SiteBanner.tsx +32 -0
  91. package/src/components/SiteBannerLayer.tsx +29 -0
  92. package/src/components/StatusBadge.tsx +12 -0
  93. package/src/components/UserMenu.tsx +103 -0
  94. package/src/components/WorkspaceSwitcher.tsx +159 -0
  95. package/src/components/__tests__/AppProviders.test.tsx +111 -0
  96. package/src/components/__tests__/CronInput.test.tsx +180 -0
  97. package/src/components/__tests__/DataTable.test.tsx +208 -0
  98. package/src/components/__tests__/ErrorBoundary.test.tsx +61 -0
  99. package/src/components/__tests__/ImpersonationBanner.test.tsx +59 -0
  100. package/src/components/__tests__/NotificationBell.test.tsx +207 -0
  101. package/src/components/__tests__/OnboardingChecklist.test.tsx +97 -0
  102. package/src/components/__tests__/SecretModal.test.tsx +112 -0
  103. package/src/components/__tests__/SiteBannerLayer.test.tsx +44 -0
  104. package/src/components/__tests__/UserMenu.test.tsx +98 -0
  105. package/src/components/__tests__/WorkspaceSwitcher.test.tsx +162 -0
  106. package/src/components/__tests__/form.test.tsx +133 -0
  107. package/src/components/__tests__/semantic-tokens.test.ts +41 -0
  108. package/src/components/__tests__/smoke.test.tsx +11 -0
  109. package/src/components/__tests__/toast.test.tsx +27 -0
  110. package/src/components/toast.tsx +23 -0
  111. package/src/components/ui/__tests__/alert-dialog.test.tsx +66 -0
  112. package/src/components/ui/__tests__/avatar.test.tsx +30 -0
  113. package/src/components/ui/__tests__/checkbox.test.tsx +18 -0
  114. package/src/components/ui/__tests__/dropdown-menu.test.tsx +33 -0
  115. package/src/components/ui/__tests__/field.test.tsx +43 -0
  116. package/src/components/ui/__tests__/overlay-size.test.tsx +84 -0
  117. package/src/components/ui/__tests__/popover.test.tsx +32 -0
  118. package/src/components/ui/__tests__/progress.test.tsx +24 -0
  119. package/src/components/ui/__tests__/select-callback.test.tsx +72 -0
  120. package/src/components/ui/__tests__/select.test.tsx +48 -0
  121. package/src/components/ui/__tests__/sidebar.test.tsx +121 -0
  122. package/src/components/ui/__tests__/stock-mirror.test.ts +39 -0
  123. package/src/components/ui/__tests__/subpath.test.tsx +38 -0
  124. package/src/components/ui/__tests__/switch.test.tsx +24 -0
  125. package/src/components/ui/__tests__/tabs.test.tsx +24 -0
  126. package/src/components/ui/__tests__/textarea.test.tsx +41 -0
  127. package/src/components/ui/alert-dialog.tsx +187 -0
  128. package/src/components/ui/avatar.tsx +108 -0
  129. package/src/components/ui/badge.tsx +51 -0
  130. package/src/components/ui/breadcrumb.tsx +124 -0
  131. package/src/components/ui/button.tsx +57 -0
  132. package/src/components/ui/card.tsx +102 -0
  133. package/src/components/ui/checkbox.tsx +28 -0
  134. package/src/components/ui/dialog.tsx +166 -0
  135. package/src/components/ui/dropdown-menu.tsx +265 -0
  136. package/src/components/ui/empty.tsx +103 -0
  137. package/src/components/ui/field.tsx +238 -0
  138. package/src/components/ui/input.tsx +19 -0
  139. package/src/components/ui/label.tsx +17 -0
  140. package/src/components/ui/popover.tsx +89 -0
  141. package/src/components/ui/progress.tsx +82 -0
  142. package/src/components/ui/select.tsx +235 -0
  143. package/src/components/ui/separator.tsx +24 -0
  144. package/src/components/ui/sheet.tsx +146 -0
  145. package/src/components/ui/sidebar.tsx +723 -0
  146. package/src/components/ui/skeleton.tsx +13 -0
  147. package/src/components/ui/switch.tsx +29 -0
  148. package/src/components/ui/tabs.tsx +79 -0
  149. package/src/components/ui/textarea.tsx +26 -0
  150. package/src/components/ui/tooltip.tsx +63 -0
  151. package/src/dev.css +1 -0
  152. package/src/hooks/use-mobile.ts +19 -0
  153. package/src/index.ts +150 -0
  154. package/src/layouts/AppShell.tsx +163 -0
  155. package/src/layouts/AuthShell.tsx +24 -0
  156. package/src/layouts/SettingsShell.tsx +129 -0
  157. package/src/layouts/__tests__/AppShell.test.tsx +163 -0
  158. package/src/layouts/__tests__/SettingsShell.test.tsx +146 -0
  159. package/src/lib/__tests__/api-error.test.ts +163 -0
  160. package/src/lib/__tests__/utils.test.ts +21 -0
  161. package/src/lib/api-error.ts +98 -0
  162. package/src/lib/utils.ts +109 -0
  163. package/src/pages/LandingPage.tsx +88 -0
  164. package/src/pages/LoginPage.tsx +291 -0
  165. package/src/pages/__tests__/LandingPage.test.tsx +58 -0
  166. package/src/pages/__tests__/LoginPage.test.tsx +229 -0
  167. package/src/pages/__tests__/RegisterPage.test.tsx +138 -0
  168. package/src/pages/__tests__/ssr-smoke.test.tsx +99 -0
  169. package/src/pages/account/NotificationPreferencesPage.tsx +138 -0
  170. package/src/pages/account/ProfileSettingsPage.tsx +189 -0
  171. package/src/pages/account/SecuritySettingsPage.tsx +214 -0
  172. package/src/pages/account/TokensPage.tsx +211 -0
  173. package/src/pages/account/__tests__/NotificationPreferencesPage.test.tsx +150 -0
  174. package/src/pages/account/__tests__/ProfileSettingsPage.test.tsx +173 -0
  175. package/src/pages/account/__tests__/SecuritySettingsPage.test.tsx +212 -0
  176. package/src/pages/account/__tests__/TokensPage.test.tsx +180 -0
  177. package/src/pages/admin/AdminSettingsPage.tsx +564 -0
  178. package/src/pages/admin/AdminShell.tsx +199 -0
  179. package/src/pages/admin/AuditPage.tsx +338 -0
  180. package/src/pages/admin/OrdersPage.tsx +642 -0
  181. package/src/pages/admin/OverviewPage.tsx +559 -0
  182. package/src/pages/admin/SSOPage.tsx +166 -0
  183. package/src/pages/admin/UserDetailPage.tsx +641 -0
  184. package/src/pages/admin/UsersPage.tsx +462 -0
  185. package/src/pages/admin/WebhookDeliveriesPage.tsx +318 -0
  186. package/src/pages/admin/WorkspaceDetailPage.tsx +551 -0
  187. package/src/pages/admin/WorkspacesPage.tsx +299 -0
  188. package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +260 -0
  189. package/src/pages/admin/__tests__/OrdersPageMarkPaid.test.tsx +207 -0
  190. package/src/pages/admin/__tests__/OverviewPageActivity.test.tsx +213 -0
  191. package/src/pages/admin/__tests__/SSOPage.test.tsx +128 -0
  192. package/src/pages/admin/__tests__/UsersPageActions.test.tsx +188 -0
  193. package/src/pages/admin/__tests__/WorkspaceDetailPageTransfer.test.tsx +170 -0
  194. package/src/pages/admin/__tests__/WorkspacesPagePlans.test.tsx +138 -0
  195. package/src/pages/admin/shared.ts +42 -0
  196. package/src/pages/auth/ForgotPasswordPage.tsx +83 -0
  197. package/src/pages/auth/InviteAcceptPage.tsx +105 -0
  198. package/src/pages/auth/RegisterPage.tsx +204 -0
  199. package/src/pages/auth/ResetPasswordPage.tsx +150 -0
  200. package/src/pages/auth/VerifyEmailPage.tsx +76 -0
  201. package/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx +67 -0
  202. package/src/pages/auth/__tests__/InviteAcceptPage.test.tsx +86 -0
  203. package/src/pages/auth/__tests__/ResetPasswordPage.test.tsx +115 -0
  204. package/src/pages/auth/__tests__/VerifyEmailPage.test.tsx +74 -0
  205. package/src/pages/workspace/BillingPage.tsx +361 -0
  206. package/src/pages/workspace/BillingSuccessPage.tsx +115 -0
  207. package/src/pages/workspace/MembersPage.tsx +568 -0
  208. package/src/pages/workspace/RolePermissionsPage.tsx +202 -0
  209. package/src/pages/workspace/SchedulesPage.tsx +387 -0
  210. package/src/pages/workspace/UsagePage.tsx +207 -0
  211. package/src/pages/workspace/WebhooksPage.tsx +506 -0
  212. package/src/pages/workspace/WorkspaceAuditPage.tsx +229 -0
  213. package/src/pages/workspace/WorkspaceGeneralPage.tsx +184 -0
  214. package/src/pages/workspace/__tests__/BillingPage.test.tsx +302 -0
  215. package/src/pages/workspace/__tests__/BillingSuccessPage.test.tsx +134 -0
  216. package/src/pages/workspace/__tests__/MembersPage.test.tsx +347 -0
  217. package/src/pages/workspace/__tests__/RolePermissionsPage.test.tsx +138 -0
  218. package/src/pages/workspace/__tests__/SchedulesPage.test.tsx +239 -0
  219. package/src/pages/workspace/__tests__/UsagePage.test.tsx +223 -0
  220. package/src/pages/workspace/__tests__/WebhooksPage.test.tsx +309 -0
  221. package/src/pages/workspace/__tests__/WorkspaceAuditPage.test.tsx +112 -0
  222. package/src/pages/workspace/__tests__/WorkspaceGeneralPage.test.tsx +164 -0
@@ -0,0 +1,61 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import { ErrorBoundary } from '../ErrorBoundary'
5
+
6
+
7
+ let shouldThrow = false
8
+ function Bomb() {
9
+ if (shouldThrow) throw new Error('炸弹爆炸')
10
+ return <div>正常内容</div>
11
+ }
12
+
13
+ describe('ErrorBoundary', () => {
14
+ beforeEach(() => {
15
+ shouldThrow = false
16
+ vi.spyOn(console, 'error').mockImplementation(() => {})
17
+ })
18
+ afterEach(() => {
19
+ vi.restoreAllMocks()
20
+ })
21
+
22
+ it('子组件 throw 时渲染 fallback 文案', () => {
23
+ shouldThrow = true
24
+ render(
25
+ <ErrorBoundary fallback={<div>自定义兜底</div>}>
26
+ <Bomb />
27
+ </ErrorBoundary>,
28
+ )
29
+ expect(screen.getByText('自定义兜底')).toBeInTheDocument()
30
+ expect(screen.queryByText('正常内容')).not.toBeInTheDocument()
31
+ })
32
+
33
+ it('函数式 fallback 的 reset() 后恢复渲染 children', () => {
34
+ shouldThrow = true
35
+ render(
36
+ <ErrorBoundary fallback={(_error, reset) => <button onClick={reset}>重试</button>}>
37
+ <Bomb />
38
+ </ErrorBoundary>,
39
+ )
40
+ shouldThrow = false
41
+ fireEvent.click(screen.getByRole('button', { name: '重试' }))
42
+ expect(screen.getByText('正常内容')).toBeInTheDocument()
43
+ })
44
+
45
+ it('无 fallback 时显示默认错误态(含"出错了"与重试按钮)', () => {
46
+ shouldThrow = true
47
+ render(<ErrorBoundary><Bomb /></ErrorBoundary>)
48
+ expect(screen.getByText('出错了')).toBeInTheDocument()
49
+ expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument()
50
+ })
51
+
52
+ it('componentDidCatch 将错误记入 console.error', () => {
53
+ shouldThrow = true
54
+ render(
55
+ <ErrorBoundary fallback={<div>ok</div>}>
56
+ <Bomb />
57
+ </ErrorBoundary>,
58
+ )
59
+ expect(console.error).toHaveBeenCalled()
60
+ })
61
+ })
@@ -0,0 +1,59 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
4
+ import { ImpersonationBanner } from '../ImpersonationBanner'
5
+ import type { PKUser } from '../../provider/PloykitProvider'
6
+
7
+
8
+
9
+ const mocks = vi.hoisted(() => ({ impersonatedBy: undefined as string | undefined, user: null as PKUser | null }))
10
+ vi.mock('../../../../hooks/src/hooks/useAuth', () => ({
11
+ useAuth: () => ({
12
+ user: mocks.user,
13
+ workspaces: [],
14
+ loading: false,
15
+ refresh: vi.fn(),
16
+ logout: vi.fn(async () => {}),
17
+ }),
18
+ }))
19
+
20
+ function impersonatedUser(impersonatedBy?: string): PKUser {
21
+ return {
22
+ email: 'bob@example.com',
23
+ display_name: 'Bob',
24
+ impersonated_by: impersonatedBy,
25
+ } as PKUser
26
+ }
27
+
28
+ describe('ImpersonationBanner(ADR 0008 模拟会话横幅)', () => {
29
+ beforeEach(() => {
30
+ mocks.impersonatedBy = undefined
31
+ mocks.user = null
32
+ })
33
+
34
+ it('模拟会话(impersonated_by 非空):显示"您正处于管理员模拟会话"警示条', () => {
35
+ mocks.user = impersonatedUser('11111111-2222-3333-4444-555555555555')
36
+ render(<ImpersonationBanner />)
37
+ expect(screen.getByText('您正处于管理员模拟会话')).toBeInTheDocument()
38
+ expect(screen.getByText('您正处于管理员模拟会话')).toBeVisible()
39
+ })
40
+
41
+ it('普通会话(无 impersonated_by 字段):不渲染横幅、不留占位', () => {
42
+ mocks.user = impersonatedUser(undefined)
43
+ const { container } = render(<ImpersonationBanner />)
44
+ expect(screen.queryByText('您正处于管理员模拟会话')).not.toBeInTheDocument()
45
+ expect(container.querySelector('[data-slot="impersonation-banner"]')).not.toBeInTheDocument()
46
+ })
47
+
48
+ it('未登录(user=null):不渲染横幅', () => {
49
+ mocks.user = null
50
+ render(<ImpersonationBanner />)
51
+ expect(screen.queryByText('您正处于管理员模拟会话')).not.toBeInTheDocument()
52
+ })
53
+
54
+ it('横幅不提供退出按钮:退出由发起管理员控制(ADR 0008 裁决)', () => {
55
+ mocks.user = impersonatedUser('11111111-2222-3333-4444-555555555555')
56
+ render(<ImpersonationBanner />)
57
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
58
+ })
59
+ })
@@ -0,0 +1,207 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
5
+ import { NotificationBell } from '../NotificationBell'
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+ const mockedGet = vi.hoisted(() => vi.fn())
14
+ const mockedPost = vi.hoisted(() => vi.fn())
15
+ vi.mock('@ploykit/client', () => ({
16
+ api: { get: mockedGet, post: mockedPost },
17
+ setWorkspaceIdProvider: vi.fn(),
18
+ }))
19
+
20
+ type N = {
21
+ id: string; type: string; title: string; body: string
22
+ link?: string | null; count: number; read_at?: string | null; created_at: string
23
+ }
24
+
25
+ const now = '2026-10-06T08:00:00Z'
26
+ const unreadItem: N = {
27
+ id: 'n-1', type: 'quota_near_limit', title: '用量接近上限',
28
+ body: '本月任务数已用 90%', link: '/settings/workspace/usage', count: 1,
29
+ read_at: null, created_at: now,
30
+ }
31
+ const readItem: N = {
32
+ id: 'n-2', type: 'task_comment', title: '已读的历史通知', body: '',
33
+ count: 2, read_at: '2026-10-05T08:00:00Z', created_at: '2026-10-05T07:00:00Z',
34
+ }
35
+
36
+
37
+ function dispatchGet(badgeCount: number, list: N[]) {
38
+ return (url: string) => {
39
+ if (url === '/api/notifications/badge') return Promise.resolve({ count: badgeCount })
40
+ if (url.startsWith('/api/notifications?')) return Promise.resolve(list)
41
+ return Promise.reject(new Error(`unexpected GET ${url}`))
42
+ }
43
+ }
44
+
45
+ function countCalls(urlPrefix: string) {
46
+ return mockedGet.mock.calls.filter(([u]) => (u as string).startsWith(urlPrefix)).length
47
+ }
48
+
49
+ function renderBell(onNavigate?: (link: string) => void) {
50
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
51
+ return render(
52
+ <QueryClientProvider client={qc}>
53
+ <NotificationBell onNavigate={onNavigate} />
54
+ </QueryClientProvider>,
55
+ )
56
+ }
57
+
58
+ beforeEach(() => {
59
+ mockedGet.mockReset()
60
+ mockedPost.mockReset()
61
+ mockedPost.mockResolvedValue(undefined)
62
+ })
63
+
64
+ describe('NotificationBell', () => {
65
+ it('未读角标:badge 查询渲染服务端未读数;0 时无角标', async () => {
66
+ mockedGet.mockImplementation(dispatchGet(3, []))
67
+ const { unmount } = renderBell()
68
+ await waitFor(() => expect(screen.getByText('3')).toBeInTheDocument())
69
+ unmount()
70
+
71
+ mockedGet.mockImplementation(dispatchGet(0, []))
72
+ renderBell()
73
+
74
+ await waitFor(() => expect(countCalls('/api/notifications/badge')).toBe(2))
75
+
76
+ expect(screen.getByRole('button', { name: '通知' })).toBeInTheDocument()
77
+ expect(screen.queryByText('0')).not.toBeInTheDocument()
78
+ })
79
+
80
+ it('未读数超 99 截断显示 99+', async () => {
81
+ mockedGet.mockImplementation(dispatchGet(120, []))
82
+ renderBell()
83
+ await waitFor(() => expect(screen.getByText('99+')).toBeInTheDocument())
84
+ })
85
+
86
+ it('打开下拉加载最近通知(limit=8);关闭态不拉列表', async () => {
87
+ const get = dispatchGet(1, [unreadItem, readItem])
88
+ mockedGet.mockImplementation(get)
89
+ renderBell()
90
+
91
+
92
+ await waitFor(() => expect(countCalls('/api/notifications?')).toBe(0))
93
+
94
+ fireEvent.click(screen.getByRole('button', { name: '通知' }))
95
+ expect(await screen.findByText('用量接近上限')).toBeInTheDocument()
96
+ expect(screen.getByText('已读的历史通知')).toBeInTheDocument()
97
+ expect(mockedGet).toHaveBeenCalledWith('/api/notifications?limit=8')
98
+ })
99
+
100
+ it('单条已读:POST /:id/read 成功后按前缀失效 badge(立即重拉)与 list(重开面板时重拉)', async () => {
101
+ mockedGet.mockImplementation(dispatchGet(2, [unreadItem]))
102
+ renderBell()
103
+ fireEvent.click(screen.getByRole('button', { name: '通知' }))
104
+ await screen.findByText('用量接近上限')
105
+ const badgeCallsBefore = countCalls('/api/notifications/badge')
106
+ const listCallsBefore = countCalls('/api/notifications?')
107
+
108
+
109
+
110
+ fireEvent.click(screen.getByText('用量接近上限'))
111
+
112
+ await waitFor(() => expect(mockedPost).toHaveBeenCalledWith('/api/notifications/n-1/read'))
113
+ await waitFor(() => {
114
+ expect(countCalls('/api/notifications/badge')).toBeGreaterThan(badgeCallsBefore)
115
+ }, { timeout: 2000 })
116
+ expect(mockedPost).not.toHaveBeenCalledWith('/api/notifications/read-all')
117
+
118
+
119
+ fireEvent.click(screen.getByRole('button', { name: '通知' }))
120
+ await waitFor(() => {
121
+ expect(countCalls('/api/notifications?')).toBeGreaterThan(listCallsBefore)
122
+ }, { timeout: 2000 })
123
+ })
124
+
125
+ it('全部已读:POST /read-all 并失效查询', async () => {
126
+ mockedGet.mockImplementation(dispatchGet(2, [unreadItem, readItem]))
127
+ renderBell()
128
+ fireEvent.click(screen.getByRole('button', { name: '通知' }))
129
+ fireEvent.click(await screen.findByText('全部已读'))
130
+
131
+ await waitFor(() => expect(mockedPost).toHaveBeenCalledWith('/api/notifications/read-all'))
132
+ await waitFor(() => {
133
+ expect(countCalls('/api/notifications/badge')).toBeGreaterThanOrEqual(2)
134
+ }, { timeout: 2000 })
135
+ })
136
+
137
+ it('外点关闭:面板外 mousedown 关闭下拉', async () => {
138
+ mockedGet.mockImplementation(dispatchGet(1, [unreadItem]))
139
+ renderBell()
140
+ fireEvent.click(screen.getByRole('button', { name: '通知' }))
141
+ await screen.findByText('用量接近上限')
142
+
143
+ fireEvent.mouseDown(document.body)
144
+ expect(screen.queryByText('用量接近上限')).not.toBeInTheDocument()
145
+
146
+
147
+ fireEvent.click(screen.getByRole('button', { name: '通知' }))
148
+ await screen.findByText('用量接近上限')
149
+ fireEvent.mouseDown(screen.getByText('用量接近上限'))
150
+ expect(screen.getByText('用量接近上限')).toBeInTheDocument()
151
+ })
152
+
153
+ it('点击带 link 的通知:onNavigate(link) 回调触发且面板关闭', async () => {
154
+ mockedGet.mockImplementation(dispatchGet(1, [unreadItem]))
155
+ const onNavigate = vi.fn()
156
+ renderBell(onNavigate)
157
+ fireEvent.click(screen.getByRole('button', { name: '通知' }))
158
+ fireEvent.click(await screen.findByText('用量接近上限'))
159
+
160
+ await waitFor(() => expect(onNavigate).toHaveBeenCalledWith('/settings/workspace/usage'))
161
+ expect(screen.queryByText('用量接近上限')).not.toBeInTheDocument()
162
+ })
163
+
164
+ it('点击无 link 的通知:不触发 onNavigate,仅走已读', async () => {
165
+ mockedGet.mockImplementation(dispatchGet(1, [{ ...unreadItem, link: null }]))
166
+ const onNavigate = vi.fn()
167
+ renderBell(onNavigate)
168
+ fireEvent.click(screen.getByRole('button', { name: '通知' }))
169
+ fireEvent.click(await screen.findByText('用量接近上限'))
170
+
171
+ await waitFor(() => expect(mockedPost).toHaveBeenCalledWith('/api/notifications/n-1/read'))
172
+ expect(onNavigate).not.toHaveBeenCalled()
173
+ })
174
+ })
175
+
176
+ describe('P2-7/P2-19:加载态与键盘化', () => {
177
+ it('P2-7:列表加载中显示"加载中…"而非"暂无通知"空态', async () => {
178
+ let resolveList!: (v: N[]) => void
179
+ mockedGet.mockImplementation((url: string) => {
180
+ if (url === '/api/notifications/badge') return Promise.resolve({ count: 0 })
181
+ if (url.startsWith('/api/notifications?')) return new Promise<N[]>((res) => { resolveList = res })
182
+ return Promise.reject(new Error(`unexpected GET ${url}`))
183
+ })
184
+ renderBell()
185
+ fireEvent.click(screen.getByRole('button', { name: '通知' }))
186
+ expect(await screen.findByText('加载中…')).toBeInTheDocument()
187
+ expect(screen.queryByText('暂无通知')).not.toBeInTheDocument()
188
+
189
+ resolveList([])
190
+ await waitFor(() => expect(screen.getByText('暂无通知')).toBeInTheDocument())
191
+ })
192
+
193
+ it('P2-19:条目为 button(Tab/Enter 可操作);触发钮带 aria-haspopup/expanded', async () => {
194
+ mockedGet.mockImplementation(dispatchGet(1, [unreadItem]))
195
+ renderBell()
196
+ const trigger = screen.getByRole('button', { name: '通知' })
197
+ expect(trigger).toHaveAttribute('aria-haspopup', 'true')
198
+ expect(trigger).toHaveAttribute('aria-expanded', 'false')
199
+
200
+ fireEvent.click(trigger)
201
+ expect(trigger).toHaveAttribute('aria-expanded', 'true')
202
+
203
+ const item = await screen.findByRole('button', { name: /用量接近上限/ })
204
+ fireEvent.click(item)
205
+ await waitFor(() => expect(mockedPost).toHaveBeenCalledWith('/api/notifications/n-1/read'))
206
+ })
207
+ })
@@ -0,0 +1,97 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import { renderToString } from 'react-dom/server'
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
5
+ import { OnboardingChecklist } from '../OnboardingChecklist'
6
+
7
+ vi.mock('../../../../hooks/src/hooks/useAuth', () => ({
8
+ useAuth: () => ({
9
+ user: { email: 'alice@example.com', display_name: 'Alice' },
10
+ workspaces: [{ id: 'ws-1', name: 'Acme' }],
11
+ loading: false,
12
+ refresh: vi.fn(),
13
+ logout: vi.fn(),
14
+ }),
15
+ }))
16
+
17
+ describe('OnboardingChecklist', () => {
18
+ beforeEach(() => localStorage.clear())
19
+
20
+ it('按 items 渲染步骤与进度值', () => {
21
+ render(
22
+ <OnboardingChecklist
23
+ items={[
24
+ { key: 'ws', label: '创建工作区', done: true },
25
+ { key: 'invite', label: '邀请成员', done: false, onClick: () => {} },
26
+ ]}
27
+ />,
28
+ )
29
+ expect(screen.getByText('创建工作区')).toBeInTheDocument()
30
+ expect(screen.getByText('邀请成员')).toBeInTheDocument()
31
+ expect(screen.getByText('50%')).toBeInTheDocument()
32
+ expect(screen.getByText('已完成 1/2 项')).toBeInTheDocument()
33
+ })
34
+
35
+ it('点击未完成且带 onClick 的步骤触发回调;已完成项不触发', () => {
36
+ const onClick = vi.fn()
37
+ render(
38
+ <OnboardingChecklist
39
+ items={[
40
+ { key: 'ws', label: '创建工作区', done: true },
41
+ { key: 'invite', label: '邀请成员', done: false, onClick },
42
+ ]}
43
+ />,
44
+ )
45
+ fireEvent.click(screen.getByRole('button', { name: /邀请成员/ }))
46
+ expect(onClick).toHaveBeenCalledTimes(1)
47
+
48
+ fireEvent.click(screen.getByRole('button', { name: /创建工作区/ }))
49
+ expect(screen.getByRole('button', { name: /创建工作区/ })).toBeDisabled()
50
+ })
51
+
52
+ it('关闭后写入 localStorage 并不再渲染', () => {
53
+ const { rerender } = render(<OnboardingChecklist items={[{ key: 'a', label: 'A', done: false }]} />)
54
+ fireEvent.click(screen.getByRole('button', { name: '关闭引导清单' }))
55
+ expect(localStorage.getItem('pk_onboarding_dismissed')).toBe('1')
56
+ rerender(<OnboardingChecklist items={[{ key: 'a', label: 'A', done: false }]} />)
57
+ expect(screen.queryByText('A')).not.toBeInTheDocument()
58
+ })
59
+
60
+ it('不传 items 时用上下文默认项(已有工作区 → 完成)', () => {
61
+ render(<OnboardingChecklist />)
62
+ expect(screen.getByText('创建第一个工作区')).toBeInTheDocument()
63
+ expect(screen.getByText('100%')).toBeInTheDocument()
64
+ })
65
+
66
+
67
+
68
+ it('localStorage 已有关闭标记:渲染期输出与标记无关(双端一致),mount 后同步为关闭', () => {
69
+ localStorage.setItem('pk_onboarding_dismissed', '1')
70
+
71
+ const html = renderToString(
72
+ <OnboardingChecklist items={[{ key: 'a', label: '引导项A', done: false }]} />,
73
+ )
74
+ expect(html).toContain('引导项A')
75
+
76
+ render(<OnboardingChecklist items={[{ key: 'a', label: '引导项A', done: false }]} />)
77
+ expect(screen.queryByText('引导项A')).not.toBeInTheDocument()
78
+ })
79
+
80
+ it('localStorage 不可用:渲染与关闭都不炸(effect/事件期兜底),按未关闭处理', () => {
81
+ const desc = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
82
+ Object.defineProperty(globalThis, 'localStorage', {
83
+ configurable: true,
84
+ get() {
85
+ throw new Error('blocked')
86
+ },
87
+ })
88
+ try {
89
+ render(<OnboardingChecklist items={[{ key: 'a', label: 'A', done: false }]} />)
90
+ expect(screen.getByText('A')).toBeInTheDocument()
91
+ fireEvent.click(screen.getByRole('button', { name: '关闭引导清单' }))
92
+ expect(screen.queryByText('A')).not.toBeInTheDocument()
93
+ } finally {
94
+ Object.defineProperty(globalThis, 'localStorage', desc!)
95
+ }
96
+ })
97
+ })
@@ -0,0 +1,112 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
4
+ import { SecretModal } from '../SecretModal'
5
+
6
+
7
+
8
+
9
+ describe('SecretModal', () => {
10
+ beforeEach(() => {
11
+ vi.stubGlobal('navigator', {
12
+ clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
13
+ })
14
+ })
15
+
16
+ function setup(secretValue: string, open: boolean, onOpenChange: (o: boolean) => void) {
17
+ return render(
18
+ <SecretModal
19
+ open={open}
20
+ onOpenChange={onOpenChange}
21
+ secretName="个人访问令牌"
22
+ secretValue={secretValue}
23
+ />,
24
+ )
25
+ }
26
+
27
+ it('轮换双密钥:确认第一个后展示第二个,复选框与关闭钮必须回到初始禁用态', async () => {
28
+ const onOpenChange = vi.fn()
29
+ const { rerender } = setup('tk_first', true, onOpenChange)
30
+
31
+
32
+ fireEvent.click(screen.getByRole('checkbox'))
33
+ expect(screen.getByRole('button', { name: '我已保存' })).toBeEnabled()
34
+
35
+
36
+ rerender(
37
+ <SecretModal
38
+ open
39
+ onOpenChange={onOpenChange}
40
+ secretName="个人访问令牌"
41
+ secretValue="tk_second"
42
+ />,
43
+ )
44
+
45
+
46
+ expect(await screen.findByText('tk_second')).toBeInTheDocument()
47
+ expect(screen.getByRole('checkbox')).not.toBeChecked()
48
+ expect(screen.getByRole('button', { name: '请先确认已保存' })).toBeDisabled()
49
+ expect(screen.queryByText('tk_first')).not.toBeInTheDocument()
50
+ })
51
+
52
+ it('关闭再打开同一组件:confirmed 同样重置', async () => {
53
+ const onOpenChange = vi.fn()
54
+ const { rerender } = setup('tk_first', true, onOpenChange)
55
+
56
+ fireEvent.click(screen.getByRole('checkbox'))
57
+ expect(screen.getByRole('button', { name: '我已保存' })).toBeEnabled()
58
+
59
+ rerender(
60
+ <SecretModal
61
+ open={false}
62
+ onOpenChange={onOpenChange}
63
+ secretName="个人访问令牌"
64
+ secretValue="tk_first"
65
+ />,
66
+ )
67
+ rerender(
68
+ <SecretModal
69
+ open
70
+ onOpenChange={onOpenChange}
71
+ secretName="个人访问令牌"
72
+ secretValue="tk_first"
73
+ />,
74
+ )
75
+
76
+ expect(await screen.findByText('tk_first')).toBeInTheDocument()
77
+ expect(screen.getByRole('checkbox')).not.toBeChecked()
78
+ expect(screen.getByRole('button', { name: '请先确认已保存' })).toBeDisabled()
79
+ })
80
+
81
+ it('复制后 copied 状态随 secretValue 变化重置', async () => {
82
+ const onOpenChange = vi.fn()
83
+ const { rerender } = setup('tk_first', true, onOpenChange)
84
+
85
+ fireEvent.click(screen.getByRole('button', { name: '复制' }))
86
+ await waitFor(() => expect(screen.getByRole('button', { name: '已复制' })).toBeInTheDocument())
87
+
88
+ rerender(
89
+ <SecretModal
90
+ open
91
+ onOpenChange={onOpenChange}
92
+ secretName="个人访问令牌"
93
+ secretValue="tk_second"
94
+ />,
95
+ )
96
+ expect(await screen.findByRole('button', { name: '复制' })).toBeInTheDocument()
97
+ })
98
+
99
+
100
+ it('复制失败如实提示手动复制,不显示"已复制"', async () => {
101
+ vi.stubGlobal('navigator', {
102
+ clipboard: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
103
+ })
104
+ setup('tk_first', true, vi.fn())
105
+
106
+ fireEvent.click(screen.getByRole('button', { name: '复制' }))
107
+ await waitFor(() =>
108
+ expect(screen.getByRole('button', { name: '复制失败,请手动复制' })).toBeInTheDocument(),
109
+ )
110
+ expect(screen.queryByRole('button', { name: '已复制' })).not.toBeInTheDocument()
111
+ })
112
+ })
@@ -0,0 +1,44 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { describe, it, expect, vi } from 'vitest'
4
+ import { SiteBannerLayer } from '../SiteBannerLayer'
5
+
6
+
7
+ const mocked = vi.hoisted(() => ({ data: undefined as Record<string, string> | undefined }))
8
+ vi.mock('../../../../hooks/src/hooks/useSiteConfig', () => ({
9
+ useSiteConfig: () => ({ data: mocked.data }),
10
+ }))
11
+
12
+ describe('SiteBannerLayer', () => {
13
+ it('无数据(/config 拉取失败/未返回)整层不渲染', () => {
14
+ mocked.data = undefined
15
+ const { container } = render(<SiteBannerLayer />)
16
+ expect(container.firstChild).toBeNull()
17
+ })
18
+
19
+ it('维护模式开 → warn 条 + 公告条(kind 跟随 banner_kind)', () => {
20
+ mocked.data = {
21
+ maintenance_mode: '1',
22
+ banner_text: '今晚 22:00 例行维护',
23
+ banner_kind: 'info',
24
+ }
25
+ render(<SiteBannerLayer />)
26
+ expect(screen.getByText(/维护中:系统维护中/)).toBeInTheDocument()
27
+ expect(screen.getByText('今晚 22:00 例行维护')).toBeInTheDocument()
28
+ })
29
+
30
+ it('公告为空 → 只渲染维护条;maintenanceText 可换文案', () => {
31
+ mocked.data = { maintenance_mode: '1', banner_text: '', banner_kind: 'info' }
32
+ render(<SiteBannerLayer maintenanceText="MyProduct 维护窗口中" />)
33
+ expect(screen.getByText('MyProduct 维护窗口中')).toBeInTheDocument()
34
+
35
+ expect(screen.queryAllByRole('status')).toHaveLength(1)
36
+ })
37
+
38
+ it('维护关 + 公告 warn → 单公告条,无维护条', () => {
39
+ mocked.data = { maintenance_mode: '0', banner_text: '紧急公告', banner_kind: 'warn' }
40
+ render(<SiteBannerLayer />)
41
+ expect(screen.getByText('紧急公告')).toBeInTheDocument()
42
+ expect(screen.queryByText(/维护中/)).not.toBeInTheDocument()
43
+ })
44
+ })
@@ -0,0 +1,98 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { MemoryRouter, Routes, Route } from 'react-router'
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
5
+ import { UserMenu } from '../UserMenu'
6
+ import { Toaster } from '../toast'
7
+ import type { PKUser } from '../../provider/PloykitProvider'
8
+
9
+
10
+ const logout = vi.fn(async () => {})
11
+ const mocks = vi.hoisted(() => ({ isPlatformAdmin: false }))
12
+ vi.mock('../../../../hooks/src/hooks/useAuth', () => ({
13
+ useAuth: () => ({
14
+ user: {
15
+ email: 'alice@example.com',
16
+ display_name: 'Alice',
17
+ avatar_url: undefined,
18
+ is_platform_admin: mocks.isPlatformAdmin,
19
+ } as PKUser,
20
+ workspaces: [],
21
+ loading: false,
22
+ refresh: vi.fn(),
23
+ logout,
24
+ }),
25
+ }))
26
+
27
+ function renderAt(path: string) {
28
+ return render(
29
+ <MemoryRouter initialEntries={[path]}>
30
+ {/* Toaster 在路由外常驻:logout 失败兜底用例断言 toast 文案实际可见
31
+ (若随切换路由卸载,跳转后 toast 不再渲染) */}
32
+ <Toaster />
33
+ <Routes>
34
+ <Route path="/login" element={<div>login-page</div>} />
35
+ <Route path="/account/profile" element={<div>account-profile-page</div>} />
36
+ <Route path="/account/notifications" element={<div>notifications-page</div>} />
37
+ <Route path="/admin" element={<div>admin-page</div>} />
38
+ <Route path="*" element={<UserMenu />} />
39
+ </Routes>
40
+ </MemoryRouter>,
41
+ )
42
+ }
43
+
44
+ describe('UserMenu', () => {
45
+ beforeEach(() => {
46
+ logout.mockClear()
47
+ mocks.isPlatformAdmin = false
48
+ })
49
+
50
+ it('渲染头像 initials fallback', () => {
51
+ renderAt('/app')
52
+ expect(screen.getByRole('button', { name: '用户菜单' })).toHaveTextContent('A')
53
+ })
54
+
55
+ it('默认项:账户设置(→/account/profile);普通用户无管理控制台', async () => {
56
+ renderAt('/app')
57
+ fireEvent.click(screen.getByRole('button', { name: '用户菜单' }))
58
+ expect(await screen.findByRole('menu')).toBeInTheDocument()
59
+ expect(screen.getByRole('menuitem', { name: '账户设置' })).toBeVisible()
60
+ expect(screen.queryByRole('menuitem', { name: '管理控制台' })).not.toBeInTheDocument()
61
+ expect(screen.queryByRole('menuitem', { name: '个人设置' })).not.toBeInTheDocument()
62
+ expect(screen.queryByRole('menuitem', { name: '通知偏好' })).not.toBeInTheDocument()
63
+ expect(screen.getByRole('menuitem', { name: '退出登录' })).toBeVisible()
64
+ })
65
+
66
+ it('账户设置点击跳 /account/profile', async () => {
67
+ renderAt('/app')
68
+ fireEvent.click(screen.getByRole('button', { name: '用户菜单' }))
69
+ fireEvent.click(await screen.findByRole('menuitem', { name: '账户设置' }))
70
+ await waitFor(() => expect(screen.getByText('account-profile-page')).toBeInTheDocument())
71
+ })
72
+
73
+ it('is_platform_admin:出现管理控制台(→/admin)', async () => {
74
+ mocks.isPlatformAdmin = true
75
+ renderAt('/app')
76
+ fireEvent.click(screen.getByRole('button', { name: '用户菜单' }))
77
+ fireEvent.click(await screen.findByRole('menuitem', { name: '管理控制台' }))
78
+ await waitFor(() => expect(screen.getByText('admin-page')).toBeInTheDocument())
79
+ })
80
+
81
+ it('退出登录调用 logout 并跳 /login', async () => {
82
+ renderAt('/app')
83
+ fireEvent.click(screen.getByRole('button', { name: '用户菜单' }))
84
+ fireEvent.click(await screen.findByRole('menuitem', { name: '退出登录' }))
85
+ await waitFor(() => expect(logout).toHaveBeenCalledTimes(1))
86
+ await waitFor(() => expect(screen.getByText('login-page')).toBeInTheDocument())
87
+ })
88
+
89
+ it('P3-11:logout 抛错不困死用户——toast 提示失败仍照常跳转登录页', async () => {
90
+ logout.mockRejectedValueOnce(new Error('network down'))
91
+ renderAt('/app')
92
+ fireEvent.click(screen.getByRole('button', { name: '用户菜单' }))
93
+ fireEvent.click(await screen.findByRole('menuitem', { name: '退出登录' }))
94
+
95
+ expect(await screen.findByText('退出登录失败,请稍后在登录页重试')).toBeInTheDocument()
96
+ await waitFor(() => expect(screen.getByText('login-page')).toBeInTheDocument())
97
+ })
98
+ })