@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,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
|
+
})
|