@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.
Files changed (152) 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 +200 -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 +155 -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 +45 -0
  74. package/dist/components/NotificationBell.d.ts.map +1 -0
  75. package/dist/components/NotificationBell.js +90 -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/coauthor-client.d.ts +72 -0
  99. package/dist/lib/coauthor-client.d.ts.map +1 -0
  100. package/dist/lib/coauthor-client.js +98 -0
  101. package/dist/lib/coauthor-client.js.map +1 -0
  102. package/dist/lib/collaboration-provider.d.ts +142 -0
  103. package/dist/lib/collaboration-provider.d.ts.map +1 -0
  104. package/dist/lib/collaboration-provider.js +168 -0
  105. package/dist/lib/collaboration-provider.js.map +1 -0
  106. package/dist/lib/comment-anchor.d.ts +86 -0
  107. package/dist/lib/comment-anchor.d.ts.map +1 -0
  108. package/dist/lib/comment-anchor.js +139 -0
  109. package/dist/lib/comment-anchor.js.map +1 -0
  110. package/dist/lib/comments-client.d.ts +106 -0
  111. package/dist/lib/comments-client.d.ts.map +1 -0
  112. package/dist/lib/comments-client.js +138 -0
  113. package/dist/lib/comments-client.js.map +1 -0
  114. package/dist/lib/notifications-client.d.ts +68 -0
  115. package/dist/lib/notifications-client.d.ts.map +1 -0
  116. package/dist/lib/notifications-client.js +85 -0
  117. package/dist/lib/notifications-client.js.map +1 -0
  118. package/dist/lib/slash-command.d.ts +39 -0
  119. package/dist/lib/slash-command.d.ts.map +1 -0
  120. package/dist/lib/slash-command.js +50 -0
  121. package/dist/lib/slash-command.js.map +1 -0
  122. package/package.json +12 -3
  123. package/src/__tests__/components/comment-mark.test.ts +99 -0
  124. package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
  125. package/src/__tests__/components/notification-bell.test.tsx +213 -0
  126. package/src/__tests__/components/offline-status.test.tsx +66 -0
  127. package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
  128. package/src/__tests__/lib/active-editor.test.ts +70 -0
  129. package/src/__tests__/lib/coauthor-client.test.ts +213 -0
  130. package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
  131. package/src/__tests__/lib/comment-anchor.test.ts +188 -0
  132. package/src/__tests__/lib/comments-client.test.ts +239 -0
  133. package/src/__tests__/lib/notifications-client.test.ts +195 -0
  134. package/src/__tests__/lib/slash-command.test.ts +146 -0
  135. package/src/components/AICoauthorBubbleMenu.tsx +187 -0
  136. package/src/components/CoauthorResultPopover.tsx +193 -0
  137. package/src/components/CommandPalette.tsx +397 -209
  138. package/src/components/CommentMark.ts +138 -0
  139. package/src/components/CommentSidePanel.tsx +521 -0
  140. package/src/components/LivePreview.tsx +117 -11
  141. package/src/components/NotificationBell.tsx +242 -0
  142. package/src/components/OfflineStatus.tsx +90 -0
  143. package/src/components/PresenceChips.tsx +150 -0
  144. package/src/components/TipTapEditor.tsx +310 -12
  145. package/src/index.ts +20 -0
  146. package/src/lib/active-editor.ts +39 -0
  147. package/src/lib/coauthor-client.ts +160 -0
  148. package/src/lib/collaboration-provider.ts +306 -0
  149. package/src/lib/comment-anchor.ts +179 -0
  150. package/src/lib/comments-client.ts +208 -0
  151. package/src/lib/notifications-client.ts +146 -0
  152. package/src/lib/slash-command.ts +67 -0
@@ -0,0 +1,425 @@
1
+ import { Awareness } from 'y-protocols/awareness'
2
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import * as Y from 'yjs'
4
+
5
+ import {
6
+ createCollaborationProvider,
7
+ summarisePresence,
8
+ type CollaborationOptions,
9
+ } from '../../lib/collaboration-provider.js'
10
+
11
+ // The provider helper requires a browser-like context (checks for `window`).
12
+ // The cms-admin vitest config runs under `node`, so we stub a minimal window
13
+ // for the whole file.
14
+ const originalWindow = (globalThis as { window?: unknown }).window
15
+ beforeAll(() => {
16
+ ;(globalThis as { window?: unknown }).window = globalThis
17
+ })
18
+ afterAll(() => {
19
+ ;(globalThis as { window?: unknown }).window = originalWindow
20
+ })
21
+
22
+ /**
23
+ * Fake WebsocketProvider that records constructor args + exposes manual
24
+ * emit hooks so tests can simulate connection-state transitions without
25
+ * opening real sockets.
26
+ */
27
+ class FakeWsProvider {
28
+ static instances: FakeWsProvider[] = []
29
+ readonly url: string
30
+ readonly room: string
31
+ readonly ydoc: Y.Doc
32
+ readonly opts: { connect?: boolean; params?: Record<string, string> }
33
+ readonly awareness: Awareness
34
+ private listeners = new Map<string, Set<(payload: unknown) => void>>()
35
+ destroyed = false
36
+
37
+ constructor(
38
+ url: string,
39
+ room: string,
40
+ ydoc: Y.Doc,
41
+ opts: { connect?: boolean; params?: Record<string, string> },
42
+ ) {
43
+ this.url = url
44
+ this.room = room
45
+ this.ydoc = ydoc
46
+ this.opts = opts
47
+ this.awareness = new Awareness(ydoc)
48
+ FakeWsProvider.instances.push(this)
49
+ }
50
+
51
+ on(event: string, handler: (payload: unknown) => void): void {
52
+ let bucket = this.listeners.get(event)
53
+ if (!bucket) {
54
+ bucket = new Set()
55
+ this.listeners.set(event, bucket)
56
+ }
57
+ bucket.add(handler)
58
+ }
59
+
60
+ emit(event: string, payload: unknown): void {
61
+ const bucket = this.listeners.get(event)
62
+ if (!bucket) return
63
+ for (const handler of bucket) handler(payload)
64
+ }
65
+
66
+ destroy(): void {
67
+ this.destroyed = true
68
+ }
69
+ }
70
+
71
+ const Ctor = FakeWsProvider as unknown as ConstructorParameters<
72
+ typeof import('y-websocket').WebsocketProvider
73
+ > extends unknown[]
74
+ ? typeof import('y-websocket').WebsocketProvider
75
+ : never
76
+
77
+ const baseOptions: CollaborationOptions = {
78
+ documentId: 'doc-1',
79
+ url: 'ws://localhost:1234/realtime',
80
+ user: { name: 'Alice', color: '#ff00aa', userId: 'user_1' },
81
+ }
82
+
83
+ describe('createCollaborationProvider — construction', () => {
84
+ beforeEach(() => {
85
+ FakeWsProvider.instances.length = 0
86
+ })
87
+ afterEach(() => {
88
+ FakeWsProvider.instances.length = 0
89
+ })
90
+
91
+ it('throws if the global window is undefined (true SSR)', () => {
92
+ const origWindow = (globalThis as { window?: unknown }).window
93
+ delete (globalThis as { window?: unknown }).window
94
+ try {
95
+ expect(() =>
96
+ createCollaborationProvider(baseOptions, { WebsocketProviderCtor: Ctor }),
97
+ ).toThrow(/browser context/)
98
+ } finally {
99
+ ;(globalThis as { window?: unknown }).window = origWindow
100
+ }
101
+ })
102
+
103
+ it('throws when documentId is missing', () => {
104
+ expect(() =>
105
+ createCollaborationProvider(
106
+ { ...baseOptions, documentId: '' },
107
+ { WebsocketProviderCtor: Ctor },
108
+ ),
109
+ ).toThrow(/documentId/)
110
+ })
111
+
112
+ it('throws when url is missing', () => {
113
+ expect(() =>
114
+ createCollaborationProvider({ ...baseOptions, url: '' }, { WebsocketProviderCtor: Ctor }),
115
+ ).toThrow(/url/)
116
+ })
117
+
118
+ it('instantiates the provider with the supplied url + documentId', () => {
119
+ const ctx = createCollaborationProvider(baseOptions, { WebsocketProviderCtor: Ctor })
120
+ expect(FakeWsProvider.instances.length).toBe(1)
121
+ const instance = FakeWsProvider.instances[0]!
122
+ expect(instance.url).toBe(baseOptions.url)
123
+ expect(instance.room).toBe(baseOptions.documentId)
124
+ expect(instance.ydoc).toBe(ctx.ydoc)
125
+ ctx.destroy()
126
+ })
127
+
128
+ it('forwards params (e.g. auth token) to the provider', () => {
129
+ createCollaborationProvider(
130
+ { ...baseOptions, params: { token: 'abc' } },
131
+ { WebsocketProviderCtor: Ctor },
132
+ )
133
+ expect(FakeWsProvider.instances[0]!.opts.params).toEqual({ token: 'abc' })
134
+ })
135
+
136
+ it('defaults connect to true', () => {
137
+ createCollaborationProvider(baseOptions, { WebsocketProviderCtor: Ctor })
138
+ expect(FakeWsProvider.instances[0]!.opts.connect).toBe(true)
139
+ })
140
+
141
+ it('respects connect: false for offline / test usage', () => {
142
+ createCollaborationProvider({ ...baseOptions, connect: false }, { WebsocketProviderCtor: Ctor })
143
+ expect(FakeWsProvider.instances[0]!.opts.connect).toBe(false)
144
+ })
145
+ })
146
+
147
+ describe('createCollaborationProvider — awareness seeding', () => {
148
+ beforeEach(() => {
149
+ FakeWsProvider.instances.length = 0
150
+ })
151
+
152
+ it('writes the user info into local awareness state', () => {
153
+ const ctx = createCollaborationProvider(baseOptions, { WebsocketProviderCtor: Ctor })
154
+ const localState = ctx.awareness.getLocalState() as { user?: typeof baseOptions.user }
155
+ expect(localState.user).toEqual(baseOptions.user)
156
+ ctx.destroy()
157
+ })
158
+
159
+ it('includes avatarUrl in awareness when supplied', () => {
160
+ const ctx = createCollaborationProvider(
161
+ { ...baseOptions, user: { ...baseOptions.user, avatarUrl: 'https://example.test/a.png' } },
162
+ { WebsocketProviderCtor: Ctor },
163
+ )
164
+ const localState = ctx.awareness.getLocalState() as { user?: { avatarUrl?: string } }
165
+ expect(localState.user?.avatarUrl).toBe('https://example.test/a.png')
166
+ ctx.destroy()
167
+ })
168
+ })
169
+
170
+ describe('createCollaborationProvider — onStatus / onError', () => {
171
+ beforeEach(() => {
172
+ FakeWsProvider.instances.length = 0
173
+ })
174
+
175
+ it('forwards normalised status transitions to onStatus', () => {
176
+ const onStatus = vi.fn()
177
+ const ctx = createCollaborationProvider(
178
+ { ...baseOptions, onStatus },
179
+ { WebsocketProviderCtor: Ctor },
180
+ )
181
+ const provider = FakeWsProvider.instances[0]!
182
+ provider.emit('status', { status: 'connecting' })
183
+ provider.emit('status', { status: 'connected' })
184
+ provider.emit('status', { status: 'disconnected' })
185
+ expect(onStatus.mock.calls.map((c) => c[0])).toEqual([
186
+ 'connecting',
187
+ 'connected',
188
+ 'disconnected',
189
+ ])
190
+ ctx.destroy()
191
+ })
192
+
193
+ it('ignores unknown status values', () => {
194
+ const onStatus = vi.fn()
195
+ const ctx = createCollaborationProvider(
196
+ { ...baseOptions, onStatus },
197
+ { WebsocketProviderCtor: Ctor },
198
+ )
199
+ FakeWsProvider.instances[0]!.emit('status', { status: 'gibberish' })
200
+ expect(onStatus).not.toHaveBeenCalled()
201
+ ctx.destroy()
202
+ })
203
+
204
+ it('calls onError when the provider fires a connection-error event', () => {
205
+ const onError = vi.fn()
206
+ const ctx = createCollaborationProvider(
207
+ { ...baseOptions, onError },
208
+ { WebsocketProviderCtor: Ctor },
209
+ )
210
+ FakeWsProvider.instances[0]!.emit('connection-error', { code: 4401 })
211
+ expect(onError).toHaveBeenCalledTimes(1)
212
+ expect((onError.mock.calls[0]![0] as Error).message).toMatch(/connection error/i)
213
+ ctx.destroy()
214
+ })
215
+ })
216
+
217
+ describe('createCollaborationProvider — destroy', () => {
218
+ beforeEach(() => {
219
+ FakeWsProvider.instances.length = 0
220
+ })
221
+
222
+ it('clears local awareness, destroys the provider, and destroys the doc', () => {
223
+ const ctx = createCollaborationProvider(baseOptions, { WebsocketProviderCtor: Ctor })
224
+ const provider = FakeWsProvider.instances[0]!
225
+ const docDestroySpy = vi.spyOn(ctx.ydoc, 'destroy')
226
+ ctx.destroy()
227
+ expect(ctx.awareness.getLocalState()).toBeNull()
228
+ expect(provider.destroyed).toBe(true)
229
+ expect(docDestroySpy).toHaveBeenCalledTimes(1)
230
+ })
231
+
232
+ it('is idempotent — second destroy does not throw', () => {
233
+ const ctx = createCollaborationProvider(baseOptions, { WebsocketProviderCtor: Ctor })
234
+ ctx.destroy()
235
+ expect(() => ctx.destroy()).not.toThrow()
236
+ })
237
+ })
238
+
239
+ describe('createCollaborationProvider — offline persistence', () => {
240
+ /** Stub IndexeddbPersistence that lets tests drive `whenSynced`. */
241
+ class FakeIdb {
242
+ static instances: FakeIdb[] = []
243
+ readonly name: string
244
+ readonly ydoc: Y.Doc
245
+ private resolve!: () => void
246
+ private reject!: (err: Error) => void
247
+ whenSynced: Promise<void>
248
+ destroyed = false
249
+ constructor(name: string, ydoc: Y.Doc) {
250
+ this.name = name
251
+ this.ydoc = ydoc
252
+ this.whenSynced = new Promise<void>((res, rej) => {
253
+ this.resolve = res
254
+ this.reject = rej
255
+ })
256
+ FakeIdb.instances.push(this)
257
+ }
258
+ fulfill() {
259
+ this.resolve()
260
+ }
261
+ fail(err: Error) {
262
+ this.reject(err)
263
+ }
264
+ destroy() {
265
+ this.destroyed = true
266
+ }
267
+ }
268
+ const IdbCtor = FakeIdb as unknown as ConstructorParameters<
269
+ typeof import('y-indexeddb').IndexeddbPersistence
270
+ > extends unknown[]
271
+ ? typeof import('y-indexeddb').IndexeddbPersistence
272
+ : never
273
+
274
+ beforeEach(() => {
275
+ FakeWsProvider.instances.length = 0
276
+ FakeIdb.instances.length = 0
277
+ })
278
+
279
+ it('marks offlineStatus as unsupported when indexedDB is unavailable', () => {
280
+ const onOfflineStatus = vi.fn()
281
+ const ctx = createCollaborationProvider(
282
+ { ...baseOptions, onOfflineStatus },
283
+ {
284
+ WebsocketProviderCtor: Ctor,
285
+ IndexeddbPersistenceCtor: IdbCtor,
286
+ isIndexedDbSupported: () => false,
287
+ },
288
+ )
289
+ expect(onOfflineStatus).toHaveBeenCalledWith('unsupported')
290
+ expect(ctx.offlinePersistence).toBeNull()
291
+ expect(ctx.offlineStatus).toBe('unsupported')
292
+ expect(FakeIdb.instances.length).toBe(0)
293
+ ctx.destroy()
294
+ })
295
+
296
+ it('opts out entirely when offline: false is passed', () => {
297
+ const onOfflineStatus = vi.fn()
298
+ const ctx = createCollaborationProvider(
299
+ { ...baseOptions, offline: false, onOfflineStatus },
300
+ {
301
+ WebsocketProviderCtor: Ctor,
302
+ IndexeddbPersistenceCtor: IdbCtor,
303
+ isIndexedDbSupported: () => true,
304
+ },
305
+ )
306
+ expect(onOfflineStatus).toHaveBeenCalledWith('unsupported')
307
+ expect(ctx.offlinePersistence).toBeNull()
308
+ expect(FakeIdb.instances.length).toBe(0)
309
+ ctx.destroy()
310
+ })
311
+
312
+ it('emits loading → ready when IndexedDB whenSynced resolves', async () => {
313
+ const onOfflineStatus = vi.fn()
314
+ const ctx = createCollaborationProvider(
315
+ { ...baseOptions, onOfflineStatus },
316
+ {
317
+ WebsocketProviderCtor: Ctor,
318
+ IndexeddbPersistenceCtor: IdbCtor,
319
+ isIndexedDbSupported: () => true,
320
+ },
321
+ )
322
+ expect(onOfflineStatus).toHaveBeenCalledWith('loading')
323
+ expect(FakeIdb.instances.length).toBe(1)
324
+ expect(FakeIdb.instances[0]!.name).toBe(`actuate-cms:${baseOptions.documentId}`)
325
+ expect(ctx.offlinePersistence).toBe(FakeIdb.instances[0]!)
326
+ FakeIdb.instances[0]!.fulfill()
327
+ await new Promise((r) => setTimeout(r, 0))
328
+ expect(onOfflineStatus).toHaveBeenLastCalledWith('ready')
329
+ expect(ctx.offlineStatus).toBe('ready')
330
+ ctx.destroy()
331
+ expect(FakeIdb.instances[0]!.destroyed).toBe(true)
332
+ })
333
+
334
+ it('emits error when whenSynced rejects', async () => {
335
+ const onOfflineStatus = vi.fn()
336
+ const onError = vi.fn()
337
+ const ctx = createCollaborationProvider(
338
+ { ...baseOptions, onOfflineStatus, onError },
339
+ {
340
+ WebsocketProviderCtor: Ctor,
341
+ IndexeddbPersistenceCtor: IdbCtor,
342
+ isIndexedDbSupported: () => true,
343
+ },
344
+ )
345
+ FakeIdb.instances[0]!.fail(new Error('quota exceeded'))
346
+ await new Promise((r) => setTimeout(r, 0))
347
+ expect(onOfflineStatus).toHaveBeenLastCalledWith('error')
348
+ expect(onError).toHaveBeenCalledTimes(1)
349
+ expect((onError.mock.calls[0]![0] as Error).message).toBe('quota exceeded')
350
+ ctx.destroy()
351
+ })
352
+
353
+ it('emits error when IndexeddbPersistence constructor throws', () => {
354
+ const onOfflineStatus = vi.fn()
355
+ const onError = vi.fn()
356
+ class ThrowingIdb {
357
+ constructor() {
358
+ throw new Error('IDB blocked')
359
+ }
360
+ }
361
+ const ctx = createCollaborationProvider(
362
+ { ...baseOptions, onOfflineStatus, onError },
363
+ {
364
+ WebsocketProviderCtor: Ctor,
365
+ IndexeddbPersistenceCtor:
366
+ ThrowingIdb as unknown as typeof FakeIdb as unknown as typeof IdbCtor,
367
+ isIndexedDbSupported: () => true,
368
+ },
369
+ )
370
+ expect(onOfflineStatus).toHaveBeenLastCalledWith('error')
371
+ expect(onError).toHaveBeenCalledTimes(1)
372
+ expect((onError.mock.calls[0]![0] as Error).message).toBe('IDB blocked')
373
+ expect(ctx.offlinePersistence).toBeNull()
374
+ ctx.destroy()
375
+ })
376
+ })
377
+
378
+ describe('summarisePresence', () => {
379
+ function makeAwareness(): Awareness {
380
+ return new Awareness(new Y.Doc())
381
+ }
382
+
383
+ it('returns the local awareness clientId', () => {
384
+ const aw = makeAwareness()
385
+ const snap = summarisePresence(aw)
386
+ expect(snap.selfClientId).toBe(aw.clientID)
387
+ })
388
+
389
+ it('includes peers that have a user object with a name', () => {
390
+ const aw = makeAwareness()
391
+ aw.setLocalState({ user: { name: 'Alice', color: '#fff', userId: 'u1' } })
392
+ const snap = summarisePresence(aw)
393
+ expect(snap.users).toHaveLength(1)
394
+ expect(snap.users[0]!.user.name).toBe('Alice')
395
+ })
396
+
397
+ it('skips entries with no user info', () => {
398
+ const aw = makeAwareness()
399
+ aw.setLocalState({})
400
+ const snap = summarisePresence(aw)
401
+ expect(snap.users).toHaveLength(0)
402
+ })
403
+
404
+ it('deduplicates by userId when peers share the same logical user', () => {
405
+ const aw = makeAwareness()
406
+ const otherDoc = new Y.Doc()
407
+ const otherAw = new Awareness(otherDoc)
408
+ aw.setLocalState({ user: { name: 'Alice', color: '#fff', userId: 'u1' } })
409
+ otherAw.setLocalState({ user: { name: 'Alice (tab 2)', color: '#000', userId: 'u1' } })
410
+ // Manually pipe otherAw's state into aw to simulate the awareness fan-out.
411
+ aw.getStates().set(otherAw.clientID, otherAw.getLocalState()!)
412
+ const snap = summarisePresence(aw)
413
+ // Both states share userId 'u1' → only one entry.
414
+ expect(snap.users).toHaveLength(1)
415
+ })
416
+
417
+ it('keeps multiple peers without userId as distinct entries', () => {
418
+ const aw = makeAwareness()
419
+ aw.setLocalState({ user: { name: 'Alice', color: '#fff' } })
420
+ aw.getStates().set(9999, { user: { name: 'Bob', color: '#000' } })
421
+ const snap = summarisePresence(aw)
422
+ expect(snap.users).toHaveLength(2)
423
+ expect(snap.users.map((u) => u.user.name).sort()).toEqual(['Alice', 'Bob'])
424
+ })
425
+ })
@@ -0,0 +1,188 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import * as Y from 'yjs'
3
+
4
+ import { decodeAnchor, encodeAnchor } from '../../lib/comment-anchor.js'
5
+
6
+ /**
7
+ * Build a Yjs doc with a TipTap-style XmlFragment containing one
8
+ * paragraph element whose text is the supplied string. Returns both the
9
+ * doc and the `Y.XmlText` leaf so tests can anchor against the right
10
+ * type (`createRelativePositionFromTypeIndex` indexes characters when
11
+ * called against an `XmlText`/`Text`, and child slots when called
12
+ * against the `XmlFragment` / element).
13
+ */
14
+ function makeDoc(text: string): { doc: Y.Doc; yText: Y.XmlText } {
15
+ const doc = new Y.Doc()
16
+ const fragment = doc.getXmlFragment('default')
17
+ const paragraph = new Y.XmlElement('paragraph')
18
+ const yText = new Y.XmlText(text)
19
+ paragraph.insert(0, [yText])
20
+ fragment.insert(0, [paragraph])
21
+ return { doc, yText }
22
+ }
23
+
24
+ describe('encodeAnchor', () => {
25
+ it('produces non-empty base64 strings for a valid selection', () => {
26
+ const { doc, yText } = makeDoc('Hello world')
27
+ const anchor = encodeAnchor({ doc, yType: yText, from: 0, to: 5 })
28
+ expect(anchor).not.toBeNull()
29
+ if (!anchor) return
30
+ expect(anchor.from.length).toBeGreaterThan(0)
31
+ expect(anchor.to.length).toBeGreaterThan(0)
32
+ expect(anchor.from).not.toBe(anchor.to)
33
+ })
34
+
35
+ it('returns null for an empty range', () => {
36
+ const { doc, yText } = makeDoc('Hello world')
37
+ expect(encodeAnchor({ doc, yType: yText, from: 3, to: 3 })).toBeNull()
38
+ })
39
+
40
+ it('returns null for an inverted range', () => {
41
+ const { doc, yText } = makeDoc('Hello world')
42
+ expect(encodeAnchor({ doc, yType: yText, from: 5, to: 1 })).toBeNull()
43
+ })
44
+
45
+ it('returns null when from < 0', () => {
46
+ const { doc, yText } = makeDoc('Hello world')
47
+ expect(encodeAnchor({ doc, yType: yText, from: -1, to: 5 })).toBeNull()
48
+ })
49
+
50
+ it('returns null when to > yType.length', () => {
51
+ const { doc, yText } = makeDoc('Hello world')
52
+ expect(encodeAnchor({ doc, yType: yText, from: 0, to: 999 })).toBeNull()
53
+ })
54
+
55
+ it('produces stable output for the same range called twice', () => {
56
+ const { doc, yText } = makeDoc('Hello world')
57
+ const a1 = encodeAnchor({ doc, yType: yText, from: 0, to: 5 })
58
+ const a2 = encodeAnchor({ doc, yType: yText, from: 0, to: 5 })
59
+ expect(a1).toEqual(a2)
60
+ })
61
+ })
62
+
63
+ describe('decodeAnchor — round trip', () => {
64
+ it('round-trips the original positions on an unchanged doc', () => {
65
+ const { doc, yText } = makeDoc('Hello world')
66
+ const anchor = encodeAnchor({ doc, yType: yText, from: 6, to: 11 })
67
+ expect(anchor).not.toBeNull()
68
+ if (!anchor) return
69
+ const resolved = decodeAnchor({ doc, anchor })
70
+ expect(resolved).not.toBeNull()
71
+ if (!resolved) return
72
+ expect(resolved.from).toBe(6)
73
+ expect(resolved.to).toBe(11)
74
+ expect(resolved.yType).toBe(yText)
75
+ })
76
+
77
+ it('returns null when the anchor blobs are not valid base64', () => {
78
+ const { doc } = makeDoc('Hello world')
79
+ expect(
80
+ decodeAnchor({
81
+ doc,
82
+ anchor: { from: 'not valid !@#$', to: 'also not !@#$' },
83
+ }),
84
+ ).toBeNull()
85
+ })
86
+ })
87
+
88
+ describe('decodeAnchor — survives concurrent edits', () => {
89
+ it('shifts the anchor right when text is inserted before it', () => {
90
+ const { doc, yText } = makeDoc('Hello world')
91
+ // Anchor "world" (chars 6..11).
92
+ const anchor = encodeAnchor({ doc, yType: yText, from: 6, to: 11 })
93
+ expect(anchor).not.toBeNull()
94
+ if (!anchor) return
95
+
96
+ // Prepend "Hey, " — five characters — to the text.
97
+ yText.insert(0, 'Hey, ')
98
+
99
+ const resolved = decodeAnchor({ doc, anchor })
100
+ expect(resolved).not.toBeNull()
101
+ if (!resolved) return
102
+ expect(yText.toString().slice(resolved.from, resolved.to)).toBe('world')
103
+ })
104
+
105
+ it('still resolves when text is appended after the anchor', () => {
106
+ const { doc, yText } = makeDoc('Hello world')
107
+ const anchor = encodeAnchor({ doc, yType: yText, from: 0, to: 5 })
108
+ expect(anchor).not.toBeNull()
109
+ if (!anchor) return
110
+
111
+ yText.insert(yText.length, '!')
112
+
113
+ const resolved = decodeAnchor({ doc, anchor })
114
+ expect(resolved).not.toBeNull()
115
+ if (!resolved) return
116
+ expect(yText.toString().slice(resolved.from, resolved.to)).toBe('Hello')
117
+ })
118
+
119
+ it('detaches (returns null) when the anchored text is deleted', () => {
120
+ const { doc, yText } = makeDoc('Hello world')
121
+ const anchor = encodeAnchor({ doc, yType: yText, from: 6, to: 11 })
122
+ expect(anchor).not.toBeNull()
123
+ if (!anchor) return
124
+
125
+ // Delete "world" — five chars starting at index 6.
126
+ yText.delete(6, 5)
127
+
128
+ const resolved = decodeAnchor({ doc, anchor })
129
+ // After deletion both ends collapse; we treat `from >= to` as detached
130
+ // so the panel can flag the comment as "anchor lost".
131
+ expect(resolved).toBeNull()
132
+ })
133
+
134
+ it('survives a sequence of inserts on both sides', () => {
135
+ const { doc, yText } = makeDoc('alpha beta gamma')
136
+ const anchor = encodeAnchor({ doc, yType: yText, from: 6, to: 10 }) // "beta"
137
+ expect(anchor).not.toBeNull()
138
+ if (!anchor) return
139
+
140
+ yText.insert(0, '>> ')
141
+ yText.insert(yText.length, ' <<')
142
+ yText.insert(0, '!')
143
+
144
+ const resolved = decodeAnchor({ doc, anchor })
145
+ expect(resolved).not.toBeNull()
146
+ if (!resolved) return
147
+ expect(yText.toString().slice(resolved.from, resolved.to)).toBe('beta')
148
+ })
149
+ })
150
+
151
+ describe('decodeAnchor — independent docs', () => {
152
+ it('survives transport between two clones (the multi-user scenario)', () => {
153
+ const a = makeDoc('Hello world')
154
+ const anchor = encodeAnchor({ doc: a.doc, yType: a.yText, from: 6, to: 11 })
155
+ expect(anchor).not.toBeNull()
156
+ if (!anchor) return
157
+
158
+ // Sync a fresh clone (the other client's doc) from author A's state.
159
+ const docB = new Y.Doc()
160
+ Y.applyUpdate(docB, Y.encodeStateAsUpdate(a.doc))
161
+
162
+ const resolved = decodeAnchor({ doc: docB, anchor })
163
+ expect(resolved).not.toBeNull()
164
+ if (!resolved) return
165
+ expect(resolved.from).toBe(6)
166
+ expect(resolved.to).toBe(11)
167
+ })
168
+
169
+ it('still resolves after the clone makes its own edit', () => {
170
+ const a = makeDoc('Hello world')
171
+ const anchor = encodeAnchor({ doc: a.doc, yType: a.yText, from: 6, to: 11 })
172
+ expect(anchor).not.toBeNull()
173
+ if (!anchor) return
174
+
175
+ const docB = new Y.Doc()
176
+ Y.applyUpdate(docB, Y.encodeStateAsUpdate(a.doc))
177
+ // Look up B's mirror of the same text node and edit it.
178
+ const fragmentB = docB.getXmlFragment('default')
179
+ const paragraphB = fragmentB.firstChild as Y.XmlElement
180
+ const yTextB = paragraphB.firstChild as Y.XmlText
181
+ yTextB.insert(0, 'Hey, ')
182
+
183
+ const resolved = decodeAnchor({ doc: docB, anchor })
184
+ expect(resolved).not.toBeNull()
185
+ if (!resolved) return
186
+ expect(yTextB.toString().slice(resolved.from, resolved.to)).toBe('world')
187
+ })
188
+ })