@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.
- package/LICENSE +21 -21
- 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__/fields/component-block-helpers.test.d.ts +7 -0
- package/dist/__tests__/fields/component-block-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/fields/component-block-helpers.test.js +592 -0
- package/dist/__tests__/fields/component-block-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/fields/ComponentBlockField.d.ts +25 -0
- package/dist/fields/ComponentBlockField.d.ts.map +1 -0
- package/dist/fields/ComponentBlockField.js +74 -0
- package/dist/fields/ComponentBlockField.js.map +1 -0
- package/dist/fields/FieldRenderer.d.ts +3 -0
- package/dist/fields/FieldRenderer.d.ts.map +1 -1
- package/dist/fields/FieldRenderer.js +3 -1
- package/dist/fields/FieldRenderer.js.map +1 -1
- package/dist/fields/PropInput.d.ts +14 -0
- package/dist/fields/PropInput.d.ts.map +1 -0
- package/dist/fields/PropInput.js +163 -0
- package/dist/fields/PropInput.js.map +1 -0
- package/dist/fields/component-block-helpers.d.ts +96 -0
- package/dist/fields/component-block-helpers.d.ts.map +1 -0
- package/dist/fields/component-block-helpers.js +323 -0
- package/dist/fields/component-block-helpers.js.map +1 -0
- package/dist/fields/index.d.ts +4 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +2 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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 +20 -4
- 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__/fields/component-block-helpers.test.ts +674 -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/fields/ComponentBlockField.tsx +179 -0
- package/src/fields/FieldRenderer.tsx +8 -0
- package/src/fields/PropInput.tsx +552 -0
- package/src/fields/component-block-helpers.ts +341 -0
- package/src/fields/index.ts +4 -0
- package/src/index.ts +27 -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,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
|
+
})
|