@actuate-media/cms-admin 0.12.0 → 0.13.0

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 (157) hide show
  1. package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
  2. package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
  3. package/dist/__tests__/components/comment-mark.test.js +89 -0
  4. package/dist/__tests__/components/comment-mark.test.js.map +1 -0
  5. package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
  6. package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/comment-side-panel.test.js +291 -0
  8. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
  9. package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
  10. package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
  11. package/dist/__tests__/components/notification-bell.test.js +346 -0
  12. package/dist/__tests__/components/notification-bell.test.js.map +1 -0
  13. package/dist/__tests__/components/offline-status.test.d.ts +2 -0
  14. package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
  15. package/dist/__tests__/components/offline-status.test.js +56 -0
  16. package/dist/__tests__/components/offline-status.test.js.map +1 -0
  17. package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
  18. package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
  19. package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
  20. package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
  21. package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
  22. package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
  23. package/dist/__tests__/lib/active-editor.test.js +57 -0
  24. package/dist/__tests__/lib/active-editor.test.js.map +1 -0
  25. package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
  26. package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
  27. package/dist/__tests__/lib/coauthor-client.test.js +173 -0
  28. package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
  29. package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
  30. package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
  31. package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
  32. package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
  33. package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
  34. package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
  35. package/dist/__tests__/lib/comment-anchor.test.js +173 -0
  36. package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
  37. package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
  38. package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
  39. package/dist/__tests__/lib/comments-client.test.js +202 -0
  40. package/dist/__tests__/lib/comments-client.test.js.map +1 -0
  41. package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
  42. package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
  43. package/dist/__tests__/lib/notifications-client.test.js +283 -0
  44. package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
  45. package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
  46. package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
  47. package/dist/__tests__/lib/slash-command.test.js +123 -0
  48. package/dist/__tests__/lib/slash-command.test.js.map +1 -0
  49. package/dist/actuate-admin.css +1 -1
  50. package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
  51. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
  52. package/dist/components/AICoauthorBubbleMenu.js +113 -0
  53. package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
  54. package/dist/components/CoauthorResultPopover.d.ts +28 -0
  55. package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
  56. package/dist/components/CoauthorResultPopover.js +42 -0
  57. package/dist/components/CoauthorResultPopover.js.map +1 -0
  58. package/dist/components/CommandPalette.d.ts +1 -1
  59. package/dist/components/CommandPalette.d.ts.map +1 -1
  60. package/dist/components/CommandPalette.js +138 -40
  61. package/dist/components/CommandPalette.js.map +1 -1
  62. package/dist/components/CommentMark.d.ts +54 -0
  63. package/dist/components/CommentMark.d.ts.map +1 -0
  64. package/dist/components/CommentMark.js +104 -0
  65. package/dist/components/CommentMark.js.map +1 -0
  66. package/dist/components/CommentSidePanel.d.ts +86 -0
  67. package/dist/components/CommentSidePanel.d.ts.map +1 -0
  68. package/dist/components/CommentSidePanel.js +165 -0
  69. package/dist/components/CommentSidePanel.js.map +1 -0
  70. package/dist/components/LivePreview.d.ts.map +1 -1
  71. package/dist/components/LivePreview.js +80 -16
  72. package/dist/components/LivePreview.js.map +1 -1
  73. package/dist/components/NotificationBell.d.ts +66 -0
  74. package/dist/components/NotificationBell.d.ts.map +1 -0
  75. package/dist/components/NotificationBell.js +142 -0
  76. package/dist/components/NotificationBell.js.map +1 -0
  77. package/dist/components/OfflineStatus.d.ts +39 -0
  78. package/dist/components/OfflineStatus.d.ts.map +1 -0
  79. package/dist/components/OfflineStatus.js +47 -0
  80. package/dist/components/OfflineStatus.js.map +1 -0
  81. package/dist/components/PresenceChips.d.ts +31 -0
  82. package/dist/components/PresenceChips.d.ts.map +1 -0
  83. package/dist/components/PresenceChips.js +58 -0
  84. package/dist/components/PresenceChips.js.map +1 -0
  85. package/dist/components/TipTapEditor.d.ts +61 -1
  86. package/dist/components/TipTapEditor.d.ts.map +1 -1
  87. package/dist/components/TipTapEditor.js +211 -8
  88. package/dist/components/TipTapEditor.js.map +1 -1
  89. package/dist/components/ui/Badge.d.ts +1 -1
  90. package/dist/index.d.ts +8 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +4 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/active-editor.d.ts +6 -0
  95. package/dist/lib/active-editor.d.ts.map +1 -0
  96. package/dist/lib/active-editor.js +35 -0
  97. package/dist/lib/active-editor.js.map +1 -0
  98. package/dist/lib/api.d.ts +8 -0
  99. package/dist/lib/api.d.ts.map +1 -1
  100. package/dist/lib/api.js +10 -0
  101. package/dist/lib/api.js.map +1 -1
  102. package/dist/lib/coauthor-client.d.ts +72 -0
  103. package/dist/lib/coauthor-client.d.ts.map +1 -0
  104. package/dist/lib/coauthor-client.js +98 -0
  105. package/dist/lib/coauthor-client.js.map +1 -0
  106. package/dist/lib/collaboration-provider.d.ts +142 -0
  107. package/dist/lib/collaboration-provider.d.ts.map +1 -0
  108. package/dist/lib/collaboration-provider.js +168 -0
  109. package/dist/lib/collaboration-provider.js.map +1 -0
  110. package/dist/lib/comment-anchor.d.ts +86 -0
  111. package/dist/lib/comment-anchor.d.ts.map +1 -0
  112. package/dist/lib/comment-anchor.js +139 -0
  113. package/dist/lib/comment-anchor.js.map +1 -0
  114. package/dist/lib/comments-client.d.ts +106 -0
  115. package/dist/lib/comments-client.d.ts.map +1 -0
  116. package/dist/lib/comments-client.js +138 -0
  117. package/dist/lib/comments-client.js.map +1 -0
  118. package/dist/lib/notifications-client.d.ts +121 -0
  119. package/dist/lib/notifications-client.d.ts.map +1 -0
  120. package/dist/lib/notifications-client.js +173 -0
  121. package/dist/lib/notifications-client.js.map +1 -0
  122. package/dist/lib/slash-command.d.ts +39 -0
  123. package/dist/lib/slash-command.d.ts.map +1 -0
  124. package/dist/lib/slash-command.js +50 -0
  125. package/dist/lib/slash-command.js.map +1 -0
  126. package/package.json +12 -3
  127. package/src/__tests__/components/comment-mark.test.ts +99 -0
  128. package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
  129. package/src/__tests__/components/notification-bell.test.tsx +379 -0
  130. package/src/__tests__/components/offline-status.test.tsx +66 -0
  131. package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
  132. package/src/__tests__/lib/active-editor.test.ts +70 -0
  133. package/src/__tests__/lib/coauthor-client.test.ts +213 -0
  134. package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
  135. package/src/__tests__/lib/comment-anchor.test.ts +188 -0
  136. package/src/__tests__/lib/comments-client.test.ts +239 -0
  137. package/src/__tests__/lib/notifications-client.test.ts +339 -0
  138. package/src/__tests__/lib/slash-command.test.ts +146 -0
  139. package/src/components/AICoauthorBubbleMenu.tsx +187 -0
  140. package/src/components/CoauthorResultPopover.tsx +193 -0
  141. package/src/components/CommandPalette.tsx +397 -209
  142. package/src/components/CommentMark.ts +138 -0
  143. package/src/components/CommentSidePanel.tsx +521 -0
  144. package/src/components/LivePreview.tsx +117 -11
  145. package/src/components/NotificationBell.tsx +314 -0
  146. package/src/components/OfflineStatus.tsx +90 -0
  147. package/src/components/PresenceChips.tsx +150 -0
  148. package/src/components/TipTapEditor.tsx +310 -12
  149. package/src/index.ts +20 -0
  150. package/src/lib/active-editor.ts +39 -0
  151. package/src/lib/api.ts +11 -0
  152. package/src/lib/coauthor-client.ts +160 -0
  153. package/src/lib/collaboration-provider.ts +306 -0
  154. package/src/lib/comment-anchor.ts +179 -0
  155. package/src/lib/comments-client.ts +208 -0
  156. package/src/lib/notifications-client.ts +292 -0
  157. package/src/lib/slash-command.ts +67 -0
@@ -0,0 +1,379 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import React from 'react'
4
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
+
6
+ import { NotificationBell, type NotificationsApi } from '../../components/NotificationBell.js'
7
+ import type {
8
+ Notification,
9
+ NotificationOutcome,
10
+ NotificationStreamHandle,
11
+ NotificationStreamOptions,
12
+ } from '../../lib/notifications-client.js'
13
+
14
+ function ok<T>(value: T): NotificationOutcome<T> {
15
+ return { ok: true, result: value }
16
+ }
17
+
18
+ function err(error = 'boom', status = 500): NotificationOutcome<never> {
19
+ return { ok: false, error, status }
20
+ }
21
+
22
+ function row(overrides: Partial<Notification> = {}): Notification {
23
+ return {
24
+ id: 'n-1',
25
+ userId: 'user-1',
26
+ kind: 'comment_reply',
27
+ documentId: 'doc-1',
28
+ commentId: 'c-1',
29
+ payload: {
30
+ kind: 'comment_reply',
31
+ actorId: 'someone',
32
+ actorName: 'Someone',
33
+ excerpt: 'hi',
34
+ },
35
+ readAt: null,
36
+ createdAt: '2026-01-01T00:00:00.000Z',
37
+ ...overrides,
38
+ }
39
+ }
40
+
41
+ function createFakeApi() {
42
+ let rows: Notification[] = []
43
+ const calls: { name: string; args: unknown[] }[] = []
44
+ const api: NotificationsApi = {
45
+ list: vi.fn(async (opts) => {
46
+ calls.push({ name: 'list', args: [opts] })
47
+ return ok(opts.includeRead ? rows : rows.filter((r) => r.readAt === null))
48
+ }),
49
+ unread: vi.fn(async () => {
50
+ calls.push({ name: 'unread', args: [] })
51
+ return ok(rows.filter((r) => r.readAt === null).length)
52
+ }),
53
+ markRead: vi.fn(async (id) => {
54
+ calls.push({ name: 'markRead', args: [id] })
55
+ const target = rows.find((r) => r.id === id)
56
+ if (!target) return err('not found', 404)
57
+ target.readAt = '2026-01-02T00:00:00.000Z'
58
+ return ok(target)
59
+ }),
60
+ markAllRead: vi.fn(async () => {
61
+ calls.push({ name: 'markAllRead', args: [] })
62
+ let n = 0
63
+ for (const r of rows) {
64
+ if (r.readAt === null) {
65
+ r.readAt = '2026-01-02T00:00:00.000Z'
66
+ n++
67
+ }
68
+ }
69
+ return ok(n)
70
+ }),
71
+ }
72
+ return {
73
+ api,
74
+ calls,
75
+ setRows: (next: Notification[]) => {
76
+ rows = next
77
+ },
78
+ rows: () => rows,
79
+ }
80
+ }
81
+
82
+ describe('NotificationBell — badge', () => {
83
+ it('shows the unread badge from the unread() call', async () => {
84
+ const fake = createFakeApi()
85
+ fake.setRows([row(), row({ id: 'n-2' })])
86
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
87
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('2'))
88
+ })
89
+
90
+ it('hides the badge when count is 0', async () => {
91
+ const fake = createFakeApi()
92
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
93
+ // Let the initial unread() settle.
94
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
95
+ expect(screen.queryByTestId('bell-badge')).toBeNull()
96
+ })
97
+
98
+ it('clamps the badge at "99+"', async () => {
99
+ const fake = createFakeApi()
100
+ fake.setRows(Array.from({ length: 150 }, (_, i) => row({ id: `n-${i}` })))
101
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
102
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('99+'))
103
+ })
104
+
105
+ it('polls unread() at pollIntervalMs', async () => {
106
+ vi.useFakeTimers({ shouldAdvanceTime: true })
107
+ try {
108
+ const fake = createFakeApi()
109
+ render(<NotificationBell api={fake.api} pollIntervalMs={5000} />)
110
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalledTimes(1))
111
+ await act(async () => {
112
+ vi.advanceTimersByTime(5000)
113
+ })
114
+ expect(fake.api.unread).toHaveBeenCalledTimes(2)
115
+ } finally {
116
+ vi.useRealTimers()
117
+ }
118
+ })
119
+ })
120
+
121
+ describe('NotificationBell — dropdown', () => {
122
+ it('shows the empty state when there are no rows', async () => {
123
+ const fake = createFakeApi()
124
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
125
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
126
+ fireEvent.click(screen.getByTestId('bell-button'))
127
+ await waitFor(() => expect(screen.getByTestId('bell-dropdown')).toBeTruthy())
128
+ await waitFor(() => expect(screen.getByText('All caught up.')).toBeTruthy())
129
+ })
130
+
131
+ it('renders one button per row with the formatted summary', async () => {
132
+ const fake = createFakeApi()
133
+ fake.setRows([
134
+ row({
135
+ id: 'n-a',
136
+ payload: {
137
+ kind: 'comment_reply',
138
+ actorId: 'x',
139
+ actorName: 'Alice',
140
+ excerpt: 'good',
141
+ },
142
+ }),
143
+ row({
144
+ id: 'n-b',
145
+ kind: 'comment_resolved',
146
+ payload: {
147
+ kind: 'comment_resolved',
148
+ actorId: 'y',
149
+ actorName: 'Bob',
150
+ rootExcerpt: 'fix',
151
+ },
152
+ }),
153
+ ])
154
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
155
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
156
+ fireEvent.click(screen.getByTestId('bell-button'))
157
+ await waitFor(() => expect(screen.getByTestId('bell-row-n-a')).toBeTruthy())
158
+ expect(screen.getByTestId('bell-row-n-a').textContent).toContain('Alice replied')
159
+ expect(screen.getByTestId('bell-row-n-b').textContent).toContain('Bob resolved')
160
+ })
161
+
162
+ it('marks a row read optimistically, then via the API', async () => {
163
+ const fake = createFakeApi()
164
+ const onSelect = vi.fn()
165
+ fake.setRows([row()])
166
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} onSelect={onSelect} />)
167
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
168
+ fireEvent.click(screen.getByTestId('bell-button'))
169
+ await waitFor(() => expect(screen.getByTestId('bell-row-n-1')).toBeTruthy())
170
+ await act(async () => {
171
+ fireEvent.click(screen.getByTestId('bell-row-n-1'))
172
+ })
173
+ expect(fake.api.markRead).toHaveBeenCalledWith('n-1')
174
+ expect(onSelect).toHaveBeenCalledTimes(1)
175
+ expect(screen.queryByTestId('bell-badge')).toBeNull()
176
+ })
177
+
178
+ it('rolls back the optimistic update when markRead fails', async () => {
179
+ const fake = createFakeApi()
180
+ ;(fake.api.markRead as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('server', 500))
181
+ const onError = vi.fn()
182
+ fake.setRows([row()])
183
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} onError={onError} />)
184
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
185
+ fireEvent.click(screen.getByTestId('bell-button'))
186
+ await waitFor(() => expect(screen.getByTestId('bell-row-n-1')).toBeTruthy())
187
+ await act(async () => {
188
+ fireEvent.click(screen.getByTestId('bell-row-n-1'))
189
+ })
190
+ expect(onError).toHaveBeenCalledWith('server')
191
+ // The badge should bounce back to 1.
192
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
193
+ })
194
+
195
+ it('marks all read in one click and disables the button after', async () => {
196
+ const fake = createFakeApi()
197
+ fake.setRows([row(), row({ id: 'n-2' })])
198
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
199
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('2'))
200
+ fireEvent.click(screen.getByTestId('bell-button'))
201
+ await waitFor(() => expect(screen.getByTestId('bell-mark-all')).toBeTruthy())
202
+ await act(async () => {
203
+ fireEvent.click(screen.getByTestId('bell-mark-all'))
204
+ })
205
+ expect(fake.api.markAllRead).toHaveBeenCalled()
206
+ expect((screen.getByTestId('bell-mark-all') as HTMLButtonElement).disabled).toBe(true)
207
+ })
208
+
209
+ it('surfaces list() errors via onError', async () => {
210
+ const fake = createFakeApi()
211
+ ;(fake.api.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('CSRF', 403))
212
+ const onError = vi.fn()
213
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} onError={onError} />)
214
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
215
+ fireEvent.click(screen.getByTestId('bell-button'))
216
+ await waitFor(() => expect(onError).toHaveBeenCalledWith('CSRF'))
217
+ })
218
+ })
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // SSE push channel (Phase 7) — verify the bell wires up subscribe(),
222
+ // processes incoming notifications, and tears down on unmount.
223
+ // ---------------------------------------------------------------------------
224
+ describe('NotificationBell — SSE push', () => {
225
+ function withSubscribe(api: NotificationsApi) {
226
+ let captured: NotificationStreamOptions | undefined
227
+ let closed = false
228
+ const handle: NotificationStreamHandle = {
229
+ close: () => {
230
+ closed = true
231
+ },
232
+ }
233
+ const subscribe = vi.fn((opts: NotificationStreamOptions) => {
234
+ captured = opts
235
+ return handle
236
+ })
237
+ return {
238
+ api: { ...api, subscribe },
239
+ push: (n: Notification) => captured?.onNotification(n),
240
+ pushError: (e: Event) => captured?.onError?.(e),
241
+ isClosed: () => closed,
242
+ }
243
+ }
244
+
245
+ it('subscribes on mount and bumps the badge when a new notification arrives', async () => {
246
+ const fake = createFakeApi()
247
+ const wired = withSubscribe(fake.api)
248
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
249
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
250
+ await act(async () => {
251
+ wired.push(row({ id: 'sse-1' }))
252
+ })
253
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
254
+ })
255
+
256
+ it('does not double-count a notification that arrives via both SSE and REST', async () => {
257
+ const fake = createFakeApi()
258
+ const wired = withSubscribe(fake.api)
259
+ fake.setRows([row({ id: 'race' })])
260
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
261
+ // Open the dropdown so the row is in `items` from the REST list.
262
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
263
+ fireEvent.click(screen.getByTestId('bell-button'))
264
+ await waitFor(() => expect(screen.getByTestId('bell-row-race')).toBeTruthy())
265
+ // Now push the same row via SSE — must NOT add a duplicate or
266
+ // bump the badge above 1.
267
+ await act(async () => {
268
+ wired.push(row({ id: 'race' }))
269
+ })
270
+ expect(screen.getAllByTestId('bell-row-race')).toHaveLength(1)
271
+ expect(screen.getByTestId('bell-badge').textContent).toBe('1')
272
+ })
273
+
274
+ it('prepends new pushed rows to the open dropdown', async () => {
275
+ const fake = createFakeApi()
276
+ const wired = withSubscribe(fake.api)
277
+ fake.setRows([row({ id: 'old' })])
278
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
279
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
280
+ fireEvent.click(screen.getByTestId('bell-button'))
281
+ await waitFor(() => expect(screen.getByTestId('bell-row-old')).toBeTruthy())
282
+ await act(async () => {
283
+ wired.push(row({ id: 'fresh' }))
284
+ })
285
+ const rows = screen.getAllByTestId(/^bell-row-/)
286
+ expect(rows[0]!.getAttribute('data-testid')).toBe('bell-row-fresh')
287
+ })
288
+
289
+ it('does NOT bump the badge when the pushed notification is already read', async () => {
290
+ const fake = createFakeApi()
291
+ const wired = withSubscribe(fake.api)
292
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
293
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
294
+ await act(async () => {
295
+ wired.push(row({ id: 'already-read', readAt: '2026-01-02T00:00:00.000Z' }))
296
+ })
297
+ // Already-read notifications still appear in the list when the
298
+ // user opens the dropdown with includeRead=true, but they must
299
+ // NOT register a fresh unread bump.
300
+ expect(screen.queryByTestId('bell-badge')).toBeNull()
301
+ })
302
+
303
+ it('closes the SSE subscription when the bell unmounts', async () => {
304
+ const fake = createFakeApi()
305
+ const wired = withSubscribe(fake.api)
306
+ const { unmount } = render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
307
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
308
+ unmount()
309
+ expect(wired.isClosed()).toBe(true)
310
+ })
311
+
312
+ it('does not surface transient SSE errors via onError (poll loop handles convergence)', async () => {
313
+ const fake = createFakeApi()
314
+ const wired = withSubscribe(fake.api)
315
+ const onError = vi.fn()
316
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} onError={onError} />)
317
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
318
+ await act(async () => {
319
+ wired.pushError(new Event('error'))
320
+ })
321
+ // Deliberately quiet — see NotificationBell SSE handler comment.
322
+ expect(onError).not.toHaveBeenCalled()
323
+ })
324
+
325
+ it('mark-all-read failure does not drop SSE rows that arrived mid-flight', async () => {
326
+ const fake = createFakeApi()
327
+ const wired = withSubscribe(fake.api)
328
+ fake.setRows([row({ id: 'before' })])
329
+ // Hold the markAllRead promise so we can interleave an SSE push.
330
+ let resolveMark: (v: NotificationOutcome<number>) => void = () => undefined
331
+ ;(wired.api.markAllRead as ReturnType<typeof vi.fn>).mockImplementationOnce(
332
+ () =>
333
+ new Promise((resolve) => {
334
+ resolveMark = resolve
335
+ }),
336
+ )
337
+ const onError = vi.fn()
338
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} onError={onError} />)
339
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
340
+ fireEvent.click(screen.getByTestId('bell-button'))
341
+ await waitFor(() => expect(screen.getByTestId('bell-row-before')).toBeTruthy())
342
+
343
+ // Click mark-all — handler optimistically marks 'before' read,
344
+ // but the API promise is still pending.
345
+ await act(async () => {
346
+ fireEvent.click(screen.getByTestId('bell-mark-all'))
347
+ })
348
+ // While in flight, a fresh notification arrives via SSE.
349
+ await act(async () => {
350
+ wired.push(row({ id: 'mid-flight' }))
351
+ })
352
+
353
+ // Now resolve the API with a failure.
354
+ await act(async () => {
355
+ resolveMark({ ok: false, error: 'server', status: 500 })
356
+ // flush promise chain
357
+ await Promise.resolve()
358
+ })
359
+
360
+ expect(onError).toHaveBeenCalledWith('server')
361
+ // The original row was rolled back to unread...
362
+ expect(screen.getByTestId('bell-row-before').getAttribute('data-read')).toBe('false')
363
+ // ...and the SSE arrival is still present (not dropped by the
364
+ // legacy snapshot-replace rollback).
365
+ expect(screen.getByTestId('bell-row-mid-flight')).toBeTruthy()
366
+ })
367
+
368
+ it('still mounts cleanly when api.subscribe is undefined (no SSE support)', async () => {
369
+ const fake = createFakeApi()
370
+ // Strip the `subscribe` member entirely — defaultApi has it,
371
+ // but consumer-provided test APIs may not.
372
+ const apiWithoutSubscribe: NotificationsApi = { ...fake.api }
373
+ delete (apiWithoutSubscribe as { subscribe?: unknown }).subscribe
374
+ render(<NotificationBell api={apiWithoutSubscribe} pollIntervalMs={0} />)
375
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
376
+ // No crash, badge from unread() shows nothing, no items.
377
+ expect(screen.queryByTestId('bell-badge')).toBeNull()
378
+ })
379
+ })
@@ -0,0 +1,66 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest'
3
+ import React from 'react'
4
+ import { render, screen } from '@testing-library/react'
5
+
6
+ import { OfflineStatus, describeOfflineStatus } from '../../components/OfflineStatus.js'
7
+
8
+ describe('describeOfflineStatus', () => {
9
+ it('error wins over everything', () => {
10
+ expect(describeOfflineStatus('connected', 'error').tone).toBe('bad')
11
+ expect(describeOfflineStatus('disconnected', 'error').tone).toBe('bad')
12
+ })
13
+
14
+ it('reports Synced when offline is unsupported but socket is connected', () => {
15
+ const out = describeOfflineStatus('connected', 'unsupported')
16
+ expect(out.tone).toBe('good')
17
+ expect(out.label).toBe('Synced')
18
+ })
19
+
20
+ it('warns when offline is unsupported and socket is disconnected', () => {
21
+ expect(describeOfflineStatus('disconnected', 'unsupported').tone).toBe('warn')
22
+ })
23
+
24
+ it('shows Connecting… when offline is unsupported and socket is connecting', () => {
25
+ expect(describeOfflineStatus('connecting', 'unsupported').label).toBe('Connecting…')
26
+ })
27
+
28
+ it('reports Restoring local copy… while IndexedDB is loading', () => {
29
+ expect(describeOfflineStatus('connecting', 'loading').label).toMatch(/Restoring/)
30
+ expect(describeOfflineStatus('connecting', 'pending').label).toMatch(/Restoring/)
31
+ })
32
+
33
+ it('reports Saved & synced when both ready', () => {
34
+ const out = describeOfflineStatus('connected', 'ready')
35
+ expect(out.tone).toBe('good')
36
+ expect(out.label).toBe('Saved & synced')
37
+ })
38
+
39
+ it('reports Saved locally when offline is ready but socket is disconnected', () => {
40
+ const out = describeOfflineStatus('disconnected', 'ready')
41
+ expect(out.tone).toBe('warn')
42
+ expect(out.label).toMatch(/Saved locally/)
43
+ })
44
+
45
+ it('reports Saved locally — reconnecting… while reconnecting', () => {
46
+ expect(describeOfflineStatus('connecting', 'ready').label).toMatch(/reconnecting/)
47
+ })
48
+ })
49
+
50
+ describe('<OfflineStatus />', () => {
51
+ it('renders the resolved label + tone', () => {
52
+ render(<OfflineStatus connection="connected" offline="ready" />)
53
+ const chip = screen.getByTestId('offline-status')
54
+ expect(chip.textContent).toContain('Saved & synced')
55
+ expect(chip.dataset.tone).toBe('good')
56
+ })
57
+
58
+ it('switches tone when conditions change', () => {
59
+ const { rerender } = render(<OfflineStatus connection="connected" offline="ready" />)
60
+ expect(screen.getByTestId('offline-status').dataset.tone).toBe('good')
61
+ rerender(<OfflineStatus connection="disconnected" offline="ready" />)
62
+ expect(screen.getByTestId('offline-status').dataset.tone).toBe('warn')
63
+ rerender(<OfflineStatus connection="connected" offline="error" />)
64
+ expect(screen.getByTestId('offline-status').dataset.tone).toBe('bad')
65
+ })
66
+ })
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { nameToInitials } from '../../components/PresenceChips.js'
4
+
5
+ describe('nameToInitials', () => {
6
+ it('returns the first letter of a single name', () => {
7
+ expect(nameToInitials('Alice')).toBe('A')
8
+ })
9
+
10
+ it('returns the first letter of two name parts', () => {
11
+ expect(nameToInitials('Alice Cooper')).toBe('AC')
12
+ })
13
+
14
+ it('caps at two letters for three+ part names', () => {
15
+ expect(nameToInitials('Mary Jane Watson')).toBe('MJ')
16
+ })
17
+
18
+ it('uppercases the result', () => {
19
+ expect(nameToInitials('alice cooper')).toBe('AC')
20
+ })
21
+
22
+ it('trims surrounding whitespace before processing', () => {
23
+ expect(nameToInitials(' Alice ')).toBe('A')
24
+ })
25
+
26
+ it('collapses multiple whitespace separators', () => {
27
+ expect(nameToInitials('Alice Cooper')).toBe('AC')
28
+ })
29
+
30
+ it('falls back to "?" for an empty string', () => {
31
+ expect(nameToInitials('')).toBe('?')
32
+ })
33
+
34
+ it('falls back to "?" for a whitespace-only string', () => {
35
+ expect(nameToInitials(' ')).toBe('?')
36
+ })
37
+
38
+ it('handles a single emoji name without crashing', () => {
39
+ const result = nameToInitials('🐱 Whiskers')
40
+ // JS upper-case is a no-op for the cat — test just confirms we don't crash
41
+ // and return *something* with two character-positions.
42
+ expect(result.length).toBeGreaterThan(0)
43
+ })
44
+ })
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import type { Editor } from '@tiptap/react'
3
+ import {
4
+ setActiveEditor,
5
+ getActiveEditor,
6
+ clearActiveEditorIf,
7
+ subscribeActiveEditor,
8
+ } from '../../lib/active-editor.js'
9
+
10
+ function fakeEditor(): Editor {
11
+ return {} as Editor
12
+ }
13
+
14
+ beforeEach(() => {
15
+ setActiveEditor(null)
16
+ })
17
+
18
+ describe('active-editor registry', () => {
19
+ it('stores and returns the active editor', () => {
20
+ const a = fakeEditor()
21
+ expect(getActiveEditor()).toBeNull()
22
+ setActiveEditor(a)
23
+ expect(getActiveEditor()).toBe(a)
24
+ })
25
+
26
+ it('replaces the active editor', () => {
27
+ const a = fakeEditor()
28
+ const b = fakeEditor()
29
+ setActiveEditor(a)
30
+ setActiveEditor(b)
31
+ expect(getActiveEditor()).toBe(b)
32
+ })
33
+
34
+ it('accepts null to clear the active editor', () => {
35
+ setActiveEditor(fakeEditor())
36
+ setActiveEditor(null)
37
+ expect(getActiveEditor()).toBeNull()
38
+ })
39
+
40
+ it('notifies subscribers when the active editor changes', () => {
41
+ const listener = vi.fn()
42
+ const unsubscribe = subscribeActiveEditor(listener)
43
+ setActiveEditor(fakeEditor())
44
+ expect(listener).toHaveBeenCalledTimes(1)
45
+ setActiveEditor(fakeEditor())
46
+ expect(listener).toHaveBeenCalledTimes(2)
47
+ unsubscribe()
48
+ setActiveEditor(fakeEditor())
49
+ expect(listener).toHaveBeenCalledTimes(2)
50
+ })
51
+
52
+ it('skips notifications when the same editor is set twice', () => {
53
+ const listener = vi.fn()
54
+ const a = fakeEditor()
55
+ subscribeActiveEditor(listener)
56
+ setActiveEditor(a)
57
+ setActiveEditor(a)
58
+ expect(listener).toHaveBeenCalledTimes(1)
59
+ })
60
+
61
+ it('clearActiveEditorIf clears only when matched', () => {
62
+ const a = fakeEditor()
63
+ const b = fakeEditor()
64
+ setActiveEditor(a)
65
+ clearActiveEditorIf(b)
66
+ expect(getActiveEditor()).toBe(a)
67
+ clearActiveEditorIf(a)
68
+ expect(getActiveEditor()).toBeNull()
69
+ })
70
+ })