@actuate-media/cms-admin 0.11.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 (183) hide show
  1. package/LICENSE +21 -21
  2. package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
  3. package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/comment-mark.test.js +89 -0
  5. package/dist/__tests__/components/comment-mark.test.js.map +1 -0
  6. package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
  7. package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
  8. package/dist/__tests__/components/comment-side-panel.test.js +291 -0
  9. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
  10. package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
  11. package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
  12. package/dist/__tests__/components/notification-bell.test.js +200 -0
  13. package/dist/__tests__/components/notification-bell.test.js.map +1 -0
  14. package/dist/__tests__/components/offline-status.test.d.ts +2 -0
  15. package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
  16. package/dist/__tests__/components/offline-status.test.js +56 -0
  17. package/dist/__tests__/components/offline-status.test.js.map +1 -0
  18. package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
  19. package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
  20. package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
  21. package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
  22. package/dist/__tests__/fields/component-block-helpers.test.d.ts +7 -0
  23. package/dist/__tests__/fields/component-block-helpers.test.d.ts.map +1 -0
  24. package/dist/__tests__/fields/component-block-helpers.test.js +592 -0
  25. package/dist/__tests__/fields/component-block-helpers.test.js.map +1 -0
  26. package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
  27. package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
  28. package/dist/__tests__/lib/active-editor.test.js +57 -0
  29. package/dist/__tests__/lib/active-editor.test.js.map +1 -0
  30. package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
  31. package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
  32. package/dist/__tests__/lib/coauthor-client.test.js +173 -0
  33. package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
  34. package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
  35. package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
  36. package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
  37. package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
  38. package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
  39. package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
  40. package/dist/__tests__/lib/comment-anchor.test.js +173 -0
  41. package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
  42. package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
  43. package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
  44. package/dist/__tests__/lib/comments-client.test.js +202 -0
  45. package/dist/__tests__/lib/comments-client.test.js.map +1 -0
  46. package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
  47. package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
  48. package/dist/__tests__/lib/notifications-client.test.js +155 -0
  49. package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
  50. package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
  51. package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
  52. package/dist/__tests__/lib/slash-command.test.js +123 -0
  53. package/dist/__tests__/lib/slash-command.test.js.map +1 -0
  54. package/dist/actuate-admin.css +1 -1
  55. package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
  56. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
  57. package/dist/components/AICoauthorBubbleMenu.js +113 -0
  58. package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
  59. package/dist/components/CoauthorResultPopover.d.ts +28 -0
  60. package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
  61. package/dist/components/CoauthorResultPopover.js +42 -0
  62. package/dist/components/CoauthorResultPopover.js.map +1 -0
  63. package/dist/components/CommandPalette.d.ts +1 -1
  64. package/dist/components/CommandPalette.d.ts.map +1 -1
  65. package/dist/components/CommandPalette.js +138 -40
  66. package/dist/components/CommandPalette.js.map +1 -1
  67. package/dist/components/CommentMark.d.ts +54 -0
  68. package/dist/components/CommentMark.d.ts.map +1 -0
  69. package/dist/components/CommentMark.js +104 -0
  70. package/dist/components/CommentMark.js.map +1 -0
  71. package/dist/components/CommentSidePanel.d.ts +86 -0
  72. package/dist/components/CommentSidePanel.d.ts.map +1 -0
  73. package/dist/components/CommentSidePanel.js +165 -0
  74. package/dist/components/CommentSidePanel.js.map +1 -0
  75. package/dist/components/LivePreview.d.ts.map +1 -1
  76. package/dist/components/LivePreview.js +80 -16
  77. package/dist/components/LivePreview.js.map +1 -1
  78. package/dist/components/NotificationBell.d.ts +45 -0
  79. package/dist/components/NotificationBell.d.ts.map +1 -0
  80. package/dist/components/NotificationBell.js +90 -0
  81. package/dist/components/NotificationBell.js.map +1 -0
  82. package/dist/components/OfflineStatus.d.ts +39 -0
  83. package/dist/components/OfflineStatus.d.ts.map +1 -0
  84. package/dist/components/OfflineStatus.js +47 -0
  85. package/dist/components/OfflineStatus.js.map +1 -0
  86. package/dist/components/PresenceChips.d.ts +31 -0
  87. package/dist/components/PresenceChips.d.ts.map +1 -0
  88. package/dist/components/PresenceChips.js +58 -0
  89. package/dist/components/PresenceChips.js.map +1 -0
  90. package/dist/components/TipTapEditor.d.ts +61 -1
  91. package/dist/components/TipTapEditor.d.ts.map +1 -1
  92. package/dist/components/TipTapEditor.js +211 -8
  93. package/dist/components/TipTapEditor.js.map +1 -1
  94. package/dist/components/ui/Badge.d.ts +1 -1
  95. package/dist/fields/ComponentBlockField.d.ts +25 -0
  96. package/dist/fields/ComponentBlockField.d.ts.map +1 -0
  97. package/dist/fields/ComponentBlockField.js +74 -0
  98. package/dist/fields/ComponentBlockField.js.map +1 -0
  99. package/dist/fields/FieldRenderer.d.ts +3 -0
  100. package/dist/fields/FieldRenderer.d.ts.map +1 -1
  101. package/dist/fields/FieldRenderer.js +3 -1
  102. package/dist/fields/FieldRenderer.js.map +1 -1
  103. package/dist/fields/PropInput.d.ts +14 -0
  104. package/dist/fields/PropInput.d.ts.map +1 -0
  105. package/dist/fields/PropInput.js +163 -0
  106. package/dist/fields/PropInput.js.map +1 -0
  107. package/dist/fields/component-block-helpers.d.ts +96 -0
  108. package/dist/fields/component-block-helpers.d.ts.map +1 -0
  109. package/dist/fields/component-block-helpers.js +323 -0
  110. package/dist/fields/component-block-helpers.js.map +1 -0
  111. package/dist/fields/index.d.ts +4 -0
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +2 -0
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/index.d.ts +10 -0
  116. package/dist/index.d.ts.map +1 -1
  117. package/dist/index.js +5 -0
  118. package/dist/index.js.map +1 -1
  119. package/dist/lib/active-editor.d.ts +6 -0
  120. package/dist/lib/active-editor.d.ts.map +1 -0
  121. package/dist/lib/active-editor.js +35 -0
  122. package/dist/lib/active-editor.js.map +1 -0
  123. package/dist/lib/coauthor-client.d.ts +72 -0
  124. package/dist/lib/coauthor-client.d.ts.map +1 -0
  125. package/dist/lib/coauthor-client.js +98 -0
  126. package/dist/lib/coauthor-client.js.map +1 -0
  127. package/dist/lib/collaboration-provider.d.ts +142 -0
  128. package/dist/lib/collaboration-provider.d.ts.map +1 -0
  129. package/dist/lib/collaboration-provider.js +168 -0
  130. package/dist/lib/collaboration-provider.js.map +1 -0
  131. package/dist/lib/comment-anchor.d.ts +86 -0
  132. package/dist/lib/comment-anchor.d.ts.map +1 -0
  133. package/dist/lib/comment-anchor.js +139 -0
  134. package/dist/lib/comment-anchor.js.map +1 -0
  135. package/dist/lib/comments-client.d.ts +106 -0
  136. package/dist/lib/comments-client.d.ts.map +1 -0
  137. package/dist/lib/comments-client.js +138 -0
  138. package/dist/lib/comments-client.js.map +1 -0
  139. package/dist/lib/notifications-client.d.ts +68 -0
  140. package/dist/lib/notifications-client.d.ts.map +1 -0
  141. package/dist/lib/notifications-client.js +85 -0
  142. package/dist/lib/notifications-client.js.map +1 -0
  143. package/dist/lib/slash-command.d.ts +39 -0
  144. package/dist/lib/slash-command.d.ts.map +1 -0
  145. package/dist/lib/slash-command.js +50 -0
  146. package/dist/lib/slash-command.js.map +1 -0
  147. package/package.json +20 -4
  148. package/src/__tests__/components/comment-mark.test.ts +99 -0
  149. package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
  150. package/src/__tests__/components/notification-bell.test.tsx +213 -0
  151. package/src/__tests__/components/offline-status.test.tsx +66 -0
  152. package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
  153. package/src/__tests__/fields/component-block-helpers.test.ts +674 -0
  154. package/src/__tests__/lib/active-editor.test.ts +70 -0
  155. package/src/__tests__/lib/coauthor-client.test.ts +213 -0
  156. package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
  157. package/src/__tests__/lib/comment-anchor.test.ts +188 -0
  158. package/src/__tests__/lib/comments-client.test.ts +239 -0
  159. package/src/__tests__/lib/notifications-client.test.ts +195 -0
  160. package/src/__tests__/lib/slash-command.test.ts +146 -0
  161. package/src/components/AICoauthorBubbleMenu.tsx +187 -0
  162. package/src/components/CoauthorResultPopover.tsx +193 -0
  163. package/src/components/CommandPalette.tsx +397 -209
  164. package/src/components/CommentMark.ts +138 -0
  165. package/src/components/CommentSidePanel.tsx +521 -0
  166. package/src/components/LivePreview.tsx +117 -11
  167. package/src/components/NotificationBell.tsx +242 -0
  168. package/src/components/OfflineStatus.tsx +90 -0
  169. package/src/components/PresenceChips.tsx +150 -0
  170. package/src/components/TipTapEditor.tsx +310 -12
  171. package/src/fields/ComponentBlockField.tsx +179 -0
  172. package/src/fields/FieldRenderer.tsx +8 -0
  173. package/src/fields/PropInput.tsx +552 -0
  174. package/src/fields/component-block-helpers.ts +341 -0
  175. package/src/fields/index.ts +4 -0
  176. package/src/index.ts +27 -0
  177. package/src/lib/active-editor.ts +39 -0
  178. package/src/lib/coauthor-client.ts +160 -0
  179. package/src/lib/collaboration-provider.ts +306 -0
  180. package/src/lib/comment-anchor.ts +179 -0
  181. package/src/lib/comments-client.ts +208 -0
  182. package/src/lib/notifications-client.ts +146 -0
  183. package/src/lib/slash-command.ts +67 -0
@@ -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
+ })
@@ -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
+ })