@actuate-media/cms-admin 0.12.0 → 0.12.1
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.
- package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
- package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-mark.test.js +89 -0
- package/dist/__tests__/components/comment-mark.test.js.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.js +291 -0
- package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
- package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
- package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
- package/dist/__tests__/components/notification-bell.test.js +200 -0
- package/dist/__tests__/components/notification-bell.test.js.map +1 -0
- package/dist/__tests__/components/offline-status.test.d.ts +2 -0
- package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
- package/dist/__tests__/components/offline-status.test.js +56 -0
- package/dist/__tests__/components/offline-status.test.js.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
- package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
- package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/active-editor.test.js +57 -0
- package/dist/__tests__/lib/active-editor.test.js.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.js +173 -0
- package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
- package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.js +173 -0
- package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
- package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
- package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comments-client.test.js +202 -0
- package/dist/__tests__/lib/comments-client.test.js.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.js +155 -0
- package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
- package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
- package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
- package/dist/__tests__/lib/slash-command.test.js +123 -0
- package/dist/__tests__/lib/slash-command.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
- package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
- package/dist/components/AICoauthorBubbleMenu.js +113 -0
- package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
- package/dist/components/CoauthorResultPopover.d.ts +28 -0
- package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
- package/dist/components/CoauthorResultPopover.js +42 -0
- package/dist/components/CoauthorResultPopover.js.map +1 -0
- package/dist/components/CommandPalette.d.ts +1 -1
- package/dist/components/CommandPalette.d.ts.map +1 -1
- package/dist/components/CommandPalette.js +138 -40
- package/dist/components/CommandPalette.js.map +1 -1
- package/dist/components/CommentMark.d.ts +54 -0
- package/dist/components/CommentMark.d.ts.map +1 -0
- package/dist/components/CommentMark.js +104 -0
- package/dist/components/CommentMark.js.map +1 -0
- package/dist/components/CommentSidePanel.d.ts +86 -0
- package/dist/components/CommentSidePanel.d.ts.map +1 -0
- package/dist/components/CommentSidePanel.js +165 -0
- package/dist/components/CommentSidePanel.js.map +1 -0
- package/dist/components/LivePreview.d.ts.map +1 -1
- package/dist/components/LivePreview.js +80 -16
- package/dist/components/LivePreview.js.map +1 -1
- package/dist/components/NotificationBell.d.ts +45 -0
- package/dist/components/NotificationBell.d.ts.map +1 -0
- package/dist/components/NotificationBell.js +90 -0
- package/dist/components/NotificationBell.js.map +1 -0
- package/dist/components/OfflineStatus.d.ts +39 -0
- package/dist/components/OfflineStatus.d.ts.map +1 -0
- package/dist/components/OfflineStatus.js +47 -0
- package/dist/components/OfflineStatus.js.map +1 -0
- package/dist/components/PresenceChips.d.ts +31 -0
- package/dist/components/PresenceChips.d.ts.map +1 -0
- package/dist/components/PresenceChips.js +58 -0
- package/dist/components/PresenceChips.js.map +1 -0
- package/dist/components/TipTapEditor.d.ts +61 -1
- package/dist/components/TipTapEditor.d.ts.map +1 -1
- package/dist/components/TipTapEditor.js +211 -8
- package/dist/components/TipTapEditor.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/active-editor.d.ts +6 -0
- package/dist/lib/active-editor.d.ts.map +1 -0
- package/dist/lib/active-editor.js +35 -0
- package/dist/lib/active-editor.js.map +1 -0
- package/dist/lib/coauthor-client.d.ts +72 -0
- package/dist/lib/coauthor-client.d.ts.map +1 -0
- package/dist/lib/coauthor-client.js +98 -0
- package/dist/lib/coauthor-client.js.map +1 -0
- package/dist/lib/collaboration-provider.d.ts +142 -0
- package/dist/lib/collaboration-provider.d.ts.map +1 -0
- package/dist/lib/collaboration-provider.js +168 -0
- package/dist/lib/collaboration-provider.js.map +1 -0
- package/dist/lib/comment-anchor.d.ts +86 -0
- package/dist/lib/comment-anchor.d.ts.map +1 -0
- package/dist/lib/comment-anchor.js +139 -0
- package/dist/lib/comment-anchor.js.map +1 -0
- package/dist/lib/comments-client.d.ts +106 -0
- package/dist/lib/comments-client.d.ts.map +1 -0
- package/dist/lib/comments-client.js +138 -0
- package/dist/lib/comments-client.js.map +1 -0
- package/dist/lib/notifications-client.d.ts +68 -0
- package/dist/lib/notifications-client.d.ts.map +1 -0
- package/dist/lib/notifications-client.js +85 -0
- package/dist/lib/notifications-client.js.map +1 -0
- package/dist/lib/slash-command.d.ts +39 -0
- package/dist/lib/slash-command.d.ts.map +1 -0
- package/dist/lib/slash-command.js +50 -0
- package/dist/lib/slash-command.js.map +1 -0
- package/package.json +12 -3
- package/src/__tests__/components/comment-mark.test.ts +99 -0
- package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
- package/src/__tests__/components/notification-bell.test.tsx +213 -0
- package/src/__tests__/components/offline-status.test.tsx +66 -0
- package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
- package/src/__tests__/lib/active-editor.test.ts +70 -0
- package/src/__tests__/lib/coauthor-client.test.ts +213 -0
- package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
- package/src/__tests__/lib/comment-anchor.test.ts +188 -0
- package/src/__tests__/lib/comments-client.test.ts +239 -0
- package/src/__tests__/lib/notifications-client.test.ts +195 -0
- package/src/__tests__/lib/slash-command.test.ts +146 -0
- package/src/components/AICoauthorBubbleMenu.tsx +187 -0
- package/src/components/CoauthorResultPopover.tsx +193 -0
- package/src/components/CommandPalette.tsx +397 -209
- package/src/components/CommentMark.ts +138 -0
- package/src/components/CommentSidePanel.tsx +521 -0
- package/src/components/LivePreview.tsx +117 -11
- package/src/components/NotificationBell.tsx +242 -0
- package/src/components/OfflineStatus.tsx +90 -0
- package/src/components/PresenceChips.tsx +150 -0
- package/src/components/TipTapEditor.tsx +310 -12
- package/src/index.ts +20 -0
- package/src/lib/active-editor.ts +39 -0
- package/src/lib/coauthor-client.ts +160 -0
- package/src/lib/collaboration-provider.ts +306 -0
- package/src/lib/comment-anchor.ts +179 -0
- package/src/lib/comments-client.ts +208 -0
- package/src/lib/notifications-client.ts +146 -0
- package/src/lib/slash-command.ts +67 -0
|
@@ -0,0 +1,213 @@
|
|
|
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 { Notification, NotificationOutcome } from '../../lib/notifications-client.js'
|
|
8
|
+
|
|
9
|
+
function ok<T>(value: T): NotificationOutcome<T> {
|
|
10
|
+
return { ok: true, result: value }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function err(error = 'boom', status = 500): NotificationOutcome<never> {
|
|
14
|
+
return { ok: false, error, status }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function row(overrides: Partial<Notification> = {}): Notification {
|
|
18
|
+
return {
|
|
19
|
+
id: 'n-1',
|
|
20
|
+
userId: 'user-1',
|
|
21
|
+
kind: 'comment_reply',
|
|
22
|
+
documentId: 'doc-1',
|
|
23
|
+
commentId: 'c-1',
|
|
24
|
+
payload: {
|
|
25
|
+
kind: 'comment_reply',
|
|
26
|
+
actorId: 'someone',
|
|
27
|
+
actorName: 'Someone',
|
|
28
|
+
excerpt: 'hi',
|
|
29
|
+
},
|
|
30
|
+
readAt: null,
|
|
31
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
32
|
+
...overrides,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createFakeApi() {
|
|
37
|
+
let rows: Notification[] = []
|
|
38
|
+
const calls: { name: string; args: unknown[] }[] = []
|
|
39
|
+
const api: NotificationsApi = {
|
|
40
|
+
list: vi.fn(async (opts) => {
|
|
41
|
+
calls.push({ name: 'list', args: [opts] })
|
|
42
|
+
return ok(opts.includeRead ? rows : rows.filter((r) => r.readAt === null))
|
|
43
|
+
}),
|
|
44
|
+
unread: vi.fn(async () => {
|
|
45
|
+
calls.push({ name: 'unread', args: [] })
|
|
46
|
+
return ok(rows.filter((r) => r.readAt === null).length)
|
|
47
|
+
}),
|
|
48
|
+
markRead: vi.fn(async (id) => {
|
|
49
|
+
calls.push({ name: 'markRead', args: [id] })
|
|
50
|
+
const target = rows.find((r) => r.id === id)
|
|
51
|
+
if (!target) return err('not found', 404)
|
|
52
|
+
target.readAt = '2026-01-02T00:00:00.000Z'
|
|
53
|
+
return ok(target)
|
|
54
|
+
}),
|
|
55
|
+
markAllRead: vi.fn(async () => {
|
|
56
|
+
calls.push({ name: 'markAllRead', args: [] })
|
|
57
|
+
let n = 0
|
|
58
|
+
for (const r of rows) {
|
|
59
|
+
if (r.readAt === null) {
|
|
60
|
+
r.readAt = '2026-01-02T00:00:00.000Z'
|
|
61
|
+
n++
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return ok(n)
|
|
65
|
+
}),
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
api,
|
|
69
|
+
calls,
|
|
70
|
+
setRows: (next: Notification[]) => {
|
|
71
|
+
rows = next
|
|
72
|
+
},
|
|
73
|
+
rows: () => rows,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('NotificationBell — badge', () => {
|
|
78
|
+
it('shows the unread badge from the unread() call', async () => {
|
|
79
|
+
const fake = createFakeApi()
|
|
80
|
+
fake.setRows([row(), row({ id: 'n-2' })])
|
|
81
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
|
|
82
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('2'))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('hides the badge when count is 0', async () => {
|
|
86
|
+
const fake = createFakeApi()
|
|
87
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
|
|
88
|
+
// Let the initial unread() settle.
|
|
89
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
90
|
+
expect(screen.queryByTestId('bell-badge')).toBeNull()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('clamps the badge at "99+"', async () => {
|
|
94
|
+
const fake = createFakeApi()
|
|
95
|
+
fake.setRows(Array.from({ length: 150 }, (_, i) => row({ id: `n-${i}` })))
|
|
96
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
|
|
97
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('99+'))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('polls unread() at pollIntervalMs', async () => {
|
|
101
|
+
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
102
|
+
try {
|
|
103
|
+
const fake = createFakeApi()
|
|
104
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={5000} />)
|
|
105
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalledTimes(1))
|
|
106
|
+
await act(async () => {
|
|
107
|
+
vi.advanceTimersByTime(5000)
|
|
108
|
+
})
|
|
109
|
+
expect(fake.api.unread).toHaveBeenCalledTimes(2)
|
|
110
|
+
} finally {
|
|
111
|
+
vi.useRealTimers()
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('NotificationBell — dropdown', () => {
|
|
117
|
+
it('shows the empty state when there are no rows', async () => {
|
|
118
|
+
const fake = createFakeApi()
|
|
119
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
|
|
120
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
121
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
122
|
+
await waitFor(() => expect(screen.getByTestId('bell-dropdown')).toBeTruthy())
|
|
123
|
+
await waitFor(() => expect(screen.getByText('All caught up.')).toBeTruthy())
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('renders one button per row with the formatted summary', async () => {
|
|
127
|
+
const fake = createFakeApi()
|
|
128
|
+
fake.setRows([
|
|
129
|
+
row({
|
|
130
|
+
id: 'n-a',
|
|
131
|
+
payload: {
|
|
132
|
+
kind: 'comment_reply',
|
|
133
|
+
actorId: 'x',
|
|
134
|
+
actorName: 'Alice',
|
|
135
|
+
excerpt: 'good',
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
row({
|
|
139
|
+
id: 'n-b',
|
|
140
|
+
kind: 'comment_resolved',
|
|
141
|
+
payload: {
|
|
142
|
+
kind: 'comment_resolved',
|
|
143
|
+
actorId: 'y',
|
|
144
|
+
actorName: 'Bob',
|
|
145
|
+
rootExcerpt: 'fix',
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
])
|
|
149
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
|
|
150
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
151
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
152
|
+
await waitFor(() => expect(screen.getByTestId('bell-row-n-a')).toBeTruthy())
|
|
153
|
+
expect(screen.getByTestId('bell-row-n-a').textContent).toContain('Alice replied')
|
|
154
|
+
expect(screen.getByTestId('bell-row-n-b').textContent).toContain('Bob resolved')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('marks a row read optimistically, then via the API', async () => {
|
|
158
|
+
const fake = createFakeApi()
|
|
159
|
+
const onSelect = vi.fn()
|
|
160
|
+
fake.setRows([row()])
|
|
161
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} onSelect={onSelect} />)
|
|
162
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
163
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
164
|
+
await waitFor(() => expect(screen.getByTestId('bell-row-n-1')).toBeTruthy())
|
|
165
|
+
await act(async () => {
|
|
166
|
+
fireEvent.click(screen.getByTestId('bell-row-n-1'))
|
|
167
|
+
})
|
|
168
|
+
expect(fake.api.markRead).toHaveBeenCalledWith('n-1')
|
|
169
|
+
expect(onSelect).toHaveBeenCalledTimes(1)
|
|
170
|
+
expect(screen.queryByTestId('bell-badge')).toBeNull()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('rolls back the optimistic update when markRead fails', async () => {
|
|
174
|
+
const fake = createFakeApi()
|
|
175
|
+
;(fake.api.markRead as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('server', 500))
|
|
176
|
+
const onError = vi.fn()
|
|
177
|
+
fake.setRows([row()])
|
|
178
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} onError={onError} />)
|
|
179
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
180
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
181
|
+
await waitFor(() => expect(screen.getByTestId('bell-row-n-1')).toBeTruthy())
|
|
182
|
+
await act(async () => {
|
|
183
|
+
fireEvent.click(screen.getByTestId('bell-row-n-1'))
|
|
184
|
+
})
|
|
185
|
+
expect(onError).toHaveBeenCalledWith('server')
|
|
186
|
+
// The badge should bounce back to 1.
|
|
187
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('marks all read in one click and disables the button after', async () => {
|
|
191
|
+
const fake = createFakeApi()
|
|
192
|
+
fake.setRows([row(), row({ id: 'n-2' })])
|
|
193
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
|
|
194
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('2'))
|
|
195
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
196
|
+
await waitFor(() => expect(screen.getByTestId('bell-mark-all')).toBeTruthy())
|
|
197
|
+
await act(async () => {
|
|
198
|
+
fireEvent.click(screen.getByTestId('bell-mark-all'))
|
|
199
|
+
})
|
|
200
|
+
expect(fake.api.markAllRead).toHaveBeenCalled()
|
|
201
|
+
expect((screen.getByTestId('bell-mark-all') as HTMLButtonElement).disabled).toBe(true)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('surfaces list() errors via onError', async () => {
|
|
205
|
+
const fake = createFakeApi()
|
|
206
|
+
;(fake.api.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('CSRF', 403))
|
|
207
|
+
const onError = vi.fn()
|
|
208
|
+
render(<NotificationBell api={fake.api} pollIntervalMs={0} onError={onError} />)
|
|
209
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
210
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
211
|
+
await waitFor(() => expect(onError).toHaveBeenCalledWith('CSRF'))
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const cmsApiMock = vi.fn()
|
|
4
|
+
vi.mock('../../lib/api.js', () => ({
|
|
5
|
+
cmsApi: (...args: unknown[]) => cmsApiMock(...args),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
coauthorText,
|
|
10
|
+
coauthorActionLabel,
|
|
11
|
+
coauthorActionDescription,
|
|
12
|
+
isCoauthorAction,
|
|
13
|
+
COAUTHOR_ACTIONS,
|
|
14
|
+
COAUTHOR_TEXT_MAX_CHARS,
|
|
15
|
+
} = await import('../../lib/coauthor-client.js')
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
cmsApiMock.mockReset()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('isCoauthorAction', () => {
|
|
22
|
+
it('accepts the four canonical actions', () => {
|
|
23
|
+
expect(isCoauthorAction('rewrite')).toBe(true)
|
|
24
|
+
expect(isCoauthorAction('expand')).toBe(true)
|
|
25
|
+
expect(isCoauthorAction('compress')).toBe(true)
|
|
26
|
+
expect(isCoauthorAction('proofread')).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('rejects anything else', () => {
|
|
30
|
+
expect(isCoauthorAction('summarize')).toBe(false)
|
|
31
|
+
expect(isCoauthorAction('')).toBe(false)
|
|
32
|
+
expect(isCoauthorAction(null)).toBe(false)
|
|
33
|
+
expect(isCoauthorAction(undefined)).toBe(false)
|
|
34
|
+
expect(isCoauthorAction(42)).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('COAUTHOR_ACTIONS', () => {
|
|
39
|
+
it('contains exactly the four canonical actions', () => {
|
|
40
|
+
expect([...COAUTHOR_ACTIONS]).toEqual(['rewrite', 'expand', 'compress', 'proofread'])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('coauthorActionLabel + coauthorActionDescription', () => {
|
|
45
|
+
it('returns a non-empty label and description for each action', () => {
|
|
46
|
+
for (const action of COAUTHOR_ACTIONS) {
|
|
47
|
+
expect(coauthorActionLabel(action)).toMatch(/\S/)
|
|
48
|
+
expect(coauthorActionDescription(action)).toMatch(/\S/)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('coauthorText — input validation', () => {
|
|
54
|
+
it('returns an error when text is empty', async () => {
|
|
55
|
+
const out = await coauthorText('rewrite', '')
|
|
56
|
+
expect(out).toEqual({ ok: false, error: 'No text provided', status: 0 })
|
|
57
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns an error when text is only whitespace', async () => {
|
|
61
|
+
const out = await coauthorText('rewrite', ' \n\t ')
|
|
62
|
+
expect(out).toEqual({ ok: false, error: 'No text provided', status: 0 })
|
|
63
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns an error when text exceeds the client-side ceiling', async () => {
|
|
67
|
+
const huge = 'a'.repeat(COAUTHOR_TEXT_MAX_CHARS + 1)
|
|
68
|
+
const out = await coauthorText('rewrite', huge)
|
|
69
|
+
expect(out.ok).toBe(false)
|
|
70
|
+
if (!out.ok) {
|
|
71
|
+
expect(out.error).toMatch(/exceeds/)
|
|
72
|
+
expect(out.status).toBe(0)
|
|
73
|
+
}
|
|
74
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('returns an error for an unknown action', async () => {
|
|
78
|
+
// @ts-expect-error — exercising the runtime guard
|
|
79
|
+
const out = await coauthorText('summarize', 'hello world')
|
|
80
|
+
expect(out.ok).toBe(false)
|
|
81
|
+
if (!out.ok) {
|
|
82
|
+
expect(out.error).toMatch(/Unknown action/)
|
|
83
|
+
}
|
|
84
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('coauthorText — request shape', () => {
|
|
89
|
+
it('POSTs to /ai/coauthor with the action + text body', async () => {
|
|
90
|
+
cmsApiMock.mockResolvedValue({ data: { action: 'rewrite', text: 'output' }, status: 200 })
|
|
91
|
+
|
|
92
|
+
await coauthorText('rewrite', 'hello')
|
|
93
|
+
|
|
94
|
+
expect(cmsApiMock).toHaveBeenCalledTimes(1)
|
|
95
|
+
const [path, init] = cmsApiMock.mock.calls[0]!
|
|
96
|
+
expect(path).toBe('/ai/coauthor')
|
|
97
|
+
expect(init.method).toBe('POST')
|
|
98
|
+
expect(JSON.parse(init.body)).toEqual({ action: 'rewrite', text: 'hello' })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('forwards style/tone/instructions/targetLength when provided', async () => {
|
|
102
|
+
cmsApiMock.mockResolvedValue({ data: { action: 'expand', text: 'longer output' }, status: 200 })
|
|
103
|
+
|
|
104
|
+
await coauthorText('expand', 'seed', {
|
|
105
|
+
style: 'academic',
|
|
106
|
+
tone: 'formal',
|
|
107
|
+
instructions: 'add two examples',
|
|
108
|
+
targetLength: 120,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const init = cmsApiMock.mock.calls[0]![1]
|
|
112
|
+
expect(JSON.parse(init.body)).toEqual({
|
|
113
|
+
action: 'expand',
|
|
114
|
+
text: 'seed',
|
|
115
|
+
style: 'academic',
|
|
116
|
+
tone: 'formal',
|
|
117
|
+
instructions: 'add two examples',
|
|
118
|
+
targetLength: 120,
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('omits undefined options from the body', async () => {
|
|
123
|
+
cmsApiMock.mockResolvedValue({ data: { action: 'rewrite', text: 'out' }, status: 200 })
|
|
124
|
+
|
|
125
|
+
await coauthorText('rewrite', 'in', { style: undefined, tone: undefined })
|
|
126
|
+
|
|
127
|
+
const init = cmsApiMock.mock.calls[0]![1]
|
|
128
|
+
const body = JSON.parse(init.body)
|
|
129
|
+
expect(body).toEqual({ action: 'rewrite', text: 'in' })
|
|
130
|
+
expect('style' in body).toBe(false)
|
|
131
|
+
expect('tone' in body).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('coauthorText — response handling', () => {
|
|
136
|
+
it('returns the parsed result on a successful response', async () => {
|
|
137
|
+
cmsApiMock.mockResolvedValue({
|
|
138
|
+
data: { action: 'rewrite', text: 'rewritten' },
|
|
139
|
+
status: 200,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const out = await coauthorText('rewrite', 'original')
|
|
143
|
+
expect(out).toEqual({
|
|
144
|
+
ok: true,
|
|
145
|
+
result: { action: 'rewrite', text: 'rewritten' },
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('forwards proofread changes only when action is proofread', async () => {
|
|
150
|
+
const changes = [{ original: 'teh', corrected: 'the' }]
|
|
151
|
+
cmsApiMock.mockResolvedValue({
|
|
152
|
+
data: { action: 'proofread', text: 'the cat', changes },
|
|
153
|
+
status: 200,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const out = await coauthorText('proofread', 'teh cat')
|
|
157
|
+
expect(out.ok).toBe(true)
|
|
158
|
+
if (out.ok) {
|
|
159
|
+
expect(out.result.changes).toEqual(changes)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('drops changes when the action is not proofread', async () => {
|
|
164
|
+
cmsApiMock.mockResolvedValue({
|
|
165
|
+
data: { action: 'rewrite', text: 'rewritten', changes: [{ original: 'a', corrected: 'b' }] },
|
|
166
|
+
status: 200,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const out = await coauthorText('rewrite', 'a')
|
|
170
|
+
expect(out.ok).toBe(true)
|
|
171
|
+
if (out.ok) {
|
|
172
|
+
expect(out.result.changes).toBeUndefined()
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('returns an error when cmsApi reports an error', async () => {
|
|
177
|
+
cmsApiMock.mockResolvedValue({ error: 'rate limited', status: 429 })
|
|
178
|
+
|
|
179
|
+
const out = await coauthorText('rewrite', 'x')
|
|
180
|
+
expect(out).toEqual({ ok: false, error: 'rate limited', status: 429 })
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('synthesizes an error message when none is provided', async () => {
|
|
184
|
+
cmsApiMock.mockResolvedValue({ status: 500 })
|
|
185
|
+
|
|
186
|
+
const out = await coauthorText('rewrite', 'x')
|
|
187
|
+
expect(out.ok).toBe(false)
|
|
188
|
+
if (!out.ok) {
|
|
189
|
+
expect(out.error).toMatch(/500/)
|
|
190
|
+
expect(out.status).toBe(500)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('flags malformed responses missing a text field', async () => {
|
|
195
|
+
cmsApiMock.mockResolvedValue({ data: { action: 'rewrite' }, status: 200 })
|
|
196
|
+
|
|
197
|
+
const out = await coauthorText('rewrite', 'x')
|
|
198
|
+
expect(out.ok).toBe(false)
|
|
199
|
+
if (!out.ok) {
|
|
200
|
+
expect(out.error).toMatch(/Malformed/)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('falls back to the requested action when the server omits it', async () => {
|
|
205
|
+
cmsApiMock.mockResolvedValue({ data: { text: 'out' }, status: 200 })
|
|
206
|
+
|
|
207
|
+
const out = await coauthorText('compress', 'in')
|
|
208
|
+
expect(out.ok).toBe(true)
|
|
209
|
+
if (out.ok) {
|
|
210
|
+
expect(out.result.action).toBe('compress')
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
})
|