@paramms/chat-widget 0.1.0 → 1.0.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/README.md +138 -12
- package/dist/connection.d.ts +48 -0
- package/dist/crypto.d.ts +69 -0
- package/dist/e2e.d.ts +75 -0
- package/dist/history.d.ts +14 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +2638 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox.d.ts +20 -0
- package/dist/protocol/actions.d.ts +98 -0
- package/dist/protocol/codec.d.ts +12 -0
- package/dist/protocol/entities.d.ts +109 -0
- package/dist/protocol/frames.d.ts +248 -0
- package/dist/protocol/ids.d.ts +22 -0
- package/dist/protocol/index.d.ts +5 -0
- package/dist/react.d.ts +124 -0
- package/dist/react.js +143 -0
- package/dist/react.js.map +1 -0
- package/dist/renderer.d.ts +163 -0
- package/dist/store.d.ts +48 -0
- package/package.json +31 -2
- package/build-preview.js +0 -136
- package/index.html +0 -37
- package/src/__tests__/chatlist.test.ts +0 -133
- package/src/__tests__/connection.test.ts +0 -163
- package/src/__tests__/crypto.test.ts +0 -28
- package/src/__tests__/history.test.ts +0 -91
- package/src/__tests__/ime.test.ts +0 -93
- package/src/__tests__/render.test.ts +0 -58
- package/src/__tests__/render_new.test.ts +0 -441
- package/src/__tests__/store.test.ts +0 -86
- package/src/__tests__/x3dh.test.ts +0 -204
- package/src/connection.ts +0 -133
- package/src/crypto.ts +0 -252
- package/src/e2e.ts +0 -161
- package/src/history.ts +0 -43
- package/src/index.ts +0 -380
- package/src/outbox.ts +0 -58
- package/src/protocol/actions.ts +0 -114
- package/src/protocol/codec.ts +0 -35
- package/src/protocol/entities.ts +0 -104
- package/src/protocol/frames.ts +0 -86
- package/src/protocol/ids.ts +0 -27
- package/src/protocol/index.ts +0 -5
- package/src/react.tsx +0 -37
- package/src/renderer.ts +0 -906
- package/src/store.ts +0 -207
- package/tsconfig.json +0 -33
- package/vercel.json +0 -22
- package/vite.config.ts +0 -26
- package/vitest.config.ts +0 -2
|
@@ -1,441 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
3
|
-
import { asUserId, asConversationId, asMessageId, asActionId } from '../protocol/index.js'
|
|
4
|
-
import { ChatStore } from '../store.js'
|
|
5
|
-
import { Renderer } from '../renderer.js'
|
|
6
|
-
|
|
7
|
-
const me = 'alice'
|
|
8
|
-
const cid = asConversationId('c1')
|
|
9
|
-
|
|
10
|
-
function openedConv() {
|
|
11
|
-
return {
|
|
12
|
-
type: 'opened' as const,
|
|
13
|
-
conversation: {
|
|
14
|
-
id: cid, tenantId: 't' as never, profileId: 'p' as never,
|
|
15
|
-
guestId: asUserId(me), participants: [asUserId(me)],
|
|
16
|
-
state: 'open', lastSeq: 0, createdAt: 0, updatedAt: 0,
|
|
17
|
-
},
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// ── Reaction UI ───────────────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
describe('Renderer — reaction UI', () => {
|
|
24
|
-
it('renders existing reaction pills from message data', () => {
|
|
25
|
-
const root = document.createElement('div')
|
|
26
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onReact: vi.fn() }
|
|
27
|
-
const store = new ChatStore(asUserId(me))
|
|
28
|
-
const r = new Renderer(root, me, h)
|
|
29
|
-
store.apply(openedConv())
|
|
30
|
-
store.apply({
|
|
31
|
-
type: 'message', message: {
|
|
32
|
-
id: asMessageId('m1'), conversationId: cid, seq: 1,
|
|
33
|
-
senderId: asUserId('bob'), senderRole: 'agent',
|
|
34
|
-
content: { kind: 'text', text: 'Hi!' }, ts: 1,
|
|
35
|
-
reactions: { '👍': [asUserId('carol')], '❤️': [asUserId('alice'), asUserId('dave')] },
|
|
36
|
-
},
|
|
37
|
-
})
|
|
38
|
-
r.render(store)
|
|
39
|
-
|
|
40
|
-
const pills = root.querySelectorAll('.ocw-react-pill')
|
|
41
|
-
expect(pills.length).toBe(2)
|
|
42
|
-
expect(Array.from(pills).map(p => p.textContent)).toEqual(
|
|
43
|
-
expect.arrayContaining(['👍 1', '❤️ 2']),
|
|
44
|
-
)
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('marks my own reaction pill with "mine" class', () => {
|
|
48
|
-
const root = document.createElement('div')
|
|
49
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onReact: vi.fn() }
|
|
50
|
-
const store = new ChatStore(asUserId(me))
|
|
51
|
-
const r = new Renderer(root, me, h)
|
|
52
|
-
store.apply(openedConv())
|
|
53
|
-
store.apply({
|
|
54
|
-
type: 'message', message: {
|
|
55
|
-
id: asMessageId('m1'), conversationId: cid, seq: 1,
|
|
56
|
-
senderId: asUserId('bob'), senderRole: 'agent',
|
|
57
|
-
content: { kind: 'text', text: 'Hi!' }, ts: 1,
|
|
58
|
-
reactions: { '👍': [asUserId(me)] },
|
|
59
|
-
},
|
|
60
|
-
})
|
|
61
|
-
r.render(store)
|
|
62
|
-
const pill = root.querySelector('.ocw-react-pill') as HTMLElement
|
|
63
|
-
expect(pill.classList.contains('mine')).toBe(true)
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('clicking an existing reaction pill calls onReact with remove=true if already reacted', () => {
|
|
67
|
-
const root = document.createElement('div')
|
|
68
|
-
const onReact = vi.fn()
|
|
69
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onReact }
|
|
70
|
-
const store = new ChatStore(asUserId(me))
|
|
71
|
-
const r = new Renderer(root, me, h)
|
|
72
|
-
store.apply(openedConv())
|
|
73
|
-
store.apply({
|
|
74
|
-
type: 'message', message: {
|
|
75
|
-
id: asMessageId('m1'), conversationId: cid, seq: 1,
|
|
76
|
-
senderId: asUserId('bob'), senderRole: 'agent',
|
|
77
|
-
content: { kind: 'text', text: 'Hi!' }, ts: 1,
|
|
78
|
-
reactions: { '👍': [asUserId(me)] },
|
|
79
|
-
},
|
|
80
|
-
})
|
|
81
|
-
r.render(store)
|
|
82
|
-
;(root.querySelector('.ocw-react-pill') as HTMLButtonElement).click()
|
|
83
|
-
expect(onReact).toHaveBeenCalledWith('m1', '👍', true) // remove=true (was mine)
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
it('shows add-reaction (+) button for non-deleted messages with seq > 0', () => {
|
|
87
|
-
const root = document.createElement('div')
|
|
88
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onReact: vi.fn() }
|
|
89
|
-
const store = new ChatStore(asUserId(me))
|
|
90
|
-
const r = new Renderer(root, me, h)
|
|
91
|
-
store.apply(openedConv())
|
|
92
|
-
store.apply({ type: 'message', message: { id: asMessageId('m2'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'yo' }, ts: 1 } })
|
|
93
|
-
r.render(store)
|
|
94
|
-
expect(root.querySelector('.ocw-react-btn')).not.toBeNull()
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('clicking + button toggles picker visibility', () => {
|
|
98
|
-
const root = document.createElement('div')
|
|
99
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onReact: vi.fn() }
|
|
100
|
-
const store = new ChatStore(asUserId(me))
|
|
101
|
-
const r = new Renderer(root, me, h)
|
|
102
|
-
store.apply(openedConv())
|
|
103
|
-
store.apply({ type: 'message', message: { id: asMessageId('m3'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'yo' }, ts: 1 } })
|
|
104
|
-
r.render(store)
|
|
105
|
-
const addBtn = root.querySelector('.ocw-react-btn') as HTMLButtonElement
|
|
106
|
-
const picker = root.querySelector('.ocw-react-picker') as HTMLElement
|
|
107
|
-
expect(picker.style.display).toBe('none')
|
|
108
|
-
addBtn.click()
|
|
109
|
-
expect(picker.style.display).toBe('flex')
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it('clicking a picker emoji calls onReact with remove=false (new reaction)', () => {
|
|
113
|
-
const root = document.createElement('div')
|
|
114
|
-
const onReact = vi.fn()
|
|
115
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onReact }
|
|
116
|
-
const store = new ChatStore(asUserId(me))
|
|
117
|
-
const r = new Renderer(root, me, h)
|
|
118
|
-
store.apply(openedConv())
|
|
119
|
-
store.apply({ type: 'message', message: { id: asMessageId('m4'), conversationId: cid, seq: 2, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'hello' }, ts: 2 } })
|
|
120
|
-
r.render(store)
|
|
121
|
-
;(root.querySelector('.ocw-react-btn') as HTMLButtonElement).click()
|
|
122
|
-
const pickerBtns = root.querySelectorAll('.ocw-react-picker button')
|
|
123
|
-
expect(pickerBtns.length).toBeGreaterThan(0)
|
|
124
|
-
;(pickerBtns[0] as HTMLButtonElement).click()
|
|
125
|
-
expect(onReact).toHaveBeenCalledWith('m4', expect.any(String), false)
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
it('does not render reaction UI when onReact is not provided', () => {
|
|
129
|
-
const root = document.createElement('div')
|
|
130
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
|
|
131
|
-
const store = new ChatStore(asUserId(me))
|
|
132
|
-
const r = new Renderer(root, me, h)
|
|
133
|
-
store.apply(openedConv())
|
|
134
|
-
store.apply({
|
|
135
|
-
type: 'message', message: {
|
|
136
|
-
id: asMessageId('m5'), conversationId: cid, seq: 1,
|
|
137
|
-
senderId: asUserId('bob'), senderRole: 'agent',
|
|
138
|
-
content: { kind: 'text', text: 'Hi!' }, ts: 1,
|
|
139
|
-
reactions: { '👍': [asUserId('carol')] },
|
|
140
|
-
},
|
|
141
|
-
})
|
|
142
|
-
r.render(store)
|
|
143
|
-
expect(root.querySelector('.ocw-react-btn')).toBeNull()
|
|
144
|
-
expect(root.querySelector('.ocw-react-pill')).toBeNull()
|
|
145
|
-
// Falls back to plain text reaction display
|
|
146
|
-
expect(root.querySelector('.ocw-react')?.textContent).toContain('👍')
|
|
147
|
-
})
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
// ── CSAT panel ────────────────────────────────────────────────────────────────
|
|
151
|
-
|
|
152
|
-
describe('Renderer — CSAT panel', () => {
|
|
153
|
-
it('shows star rating panel when state is terminal (resolved)', () => {
|
|
154
|
-
const root = document.createElement('div')
|
|
155
|
-
const onCsat = vi.fn()
|
|
156
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onCsat }
|
|
157
|
-
const store = new ChatStore(asUserId(me))
|
|
158
|
-
const r = new Renderer(root, me, h)
|
|
159
|
-
store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state: 'resolved', lastSeq: 1, createdAt: 0, updatedAt: 0 } })
|
|
160
|
-
store.apply({ type: 'message', message: { id: asMessageId('m1'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'Done!' }, ts: 1 } })
|
|
161
|
-
r.render(store)
|
|
162
|
-
const panel = root.querySelector('.ocw-csat') as HTMLElement
|
|
163
|
-
expect(panel.style.display).not.toBe('none')
|
|
164
|
-
expect(panel.querySelectorAll('.ocw-csat-star').length).toBe(5)
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
it('does not show CSAT when state is not terminal', () => {
|
|
168
|
-
const root = document.createElement('div')
|
|
169
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onCsat: vi.fn() }
|
|
170
|
-
const store = new ChatStore(asUserId(me))
|
|
171
|
-
const r = new Renderer(root, me, h)
|
|
172
|
-
store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state: 'open', lastSeq: 0, createdAt: 0, updatedAt: 0 } })
|
|
173
|
-
r.render(store)
|
|
174
|
-
const panel = root.querySelector('.ocw-csat') as HTMLElement
|
|
175
|
-
expect(panel.style.display).toBe('none')
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
it('clicking a star calls onCsat with the correct score and replaces panel', () => {
|
|
179
|
-
const root = document.createElement('div')
|
|
180
|
-
const onCsat = vi.fn()
|
|
181
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onCsat }
|
|
182
|
-
const store = new ChatStore(asUserId(me))
|
|
183
|
-
const r = new Renderer(root, me, h)
|
|
184
|
-
store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state: 'resolved', lastSeq: 1, createdAt: 0, updatedAt: 0 } })
|
|
185
|
-
store.apply({ type: 'message', message: { id: asMessageId('m1'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'Done!' }, ts: 1 } })
|
|
186
|
-
r.render(store)
|
|
187
|
-
|
|
188
|
-
const stars = root.querySelectorAll('.ocw-csat-star')
|
|
189
|
-
;(stars[3] as HTMLButtonElement).click() // 4th star = score 4
|
|
190
|
-
expect(onCsat).toHaveBeenCalledWith(4)
|
|
191
|
-
// Panel replaced with thank-you
|
|
192
|
-
expect(root.querySelector('.ocw-csat')!.textContent).toContain('4★')
|
|
193
|
-
expect(root.querySelector('.ocw-csat-star')).toBeNull()
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
it('does not show CSAT when onCsat handler not provided', () => {
|
|
197
|
-
const root = document.createElement('div')
|
|
198
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
|
|
199
|
-
const store = new ChatStore(asUserId(me))
|
|
200
|
-
const r = new Renderer(root, me, h)
|
|
201
|
-
store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state: 'resolved', lastSeq: 1, createdAt: 0, updatedAt: 0 } })
|
|
202
|
-
store.apply({ type: 'message', message: { id: asMessageId('m1'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'Done!' }, ts: 1 } })
|
|
203
|
-
r.render(store)
|
|
204
|
-
expect((root.querySelector('.ocw-csat') as HTMLElement).style.display).toBe('none')
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
it('CSAT does not show again after being submitted', () => {
|
|
208
|
-
const root = document.createElement('div')
|
|
209
|
-
const onCsat = vi.fn()
|
|
210
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onCsat }
|
|
211
|
-
const store = new ChatStore(asUserId(me))
|
|
212
|
-
const r = new Renderer(root, me, h)
|
|
213
|
-
store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state: 'resolved', lastSeq: 1, createdAt: 0, updatedAt: 0 } })
|
|
214
|
-
store.apply({ type: 'message', message: { id: asMessageId('m1'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'Done!' }, ts: 1 } })
|
|
215
|
-
r.render(store)
|
|
216
|
-
;(root.querySelectorAll('.ocw-csat-star')[0] as HTMLButtonElement).click()
|
|
217
|
-
// Re-render: CSAT should remain in thank-you state, not re-show stars
|
|
218
|
-
r.render(store)
|
|
219
|
-
expect(root.querySelector('.ocw-csat-star')).toBeNull()
|
|
220
|
-
expect(onCsat).toHaveBeenCalledTimes(1)
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
it('shows for other terminal states: sold, closed, issued, checked_out', () => {
|
|
224
|
-
for (const state of ['sold', 'closed', 'issued', 'checked_out']) {
|
|
225
|
-
const root = document.createElement('div')
|
|
226
|
-
const onCsat = vi.fn()
|
|
227
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onCsat }
|
|
228
|
-
const store = new ChatStore(asUserId(me))
|
|
229
|
-
const r = new Renderer(root, me, h)
|
|
230
|
-
store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state, lastSeq: 1, createdAt: 0, updatedAt: 0 } })
|
|
231
|
-
store.apply({ type: 'message', message: { id: asMessageId('mx'), conversationId: cid, seq: 1, senderId: asUserId('agent'), senderRole: 'agent', content: { kind: 'text', text: 'done' }, ts: 1 } })
|
|
232
|
-
r.render(store)
|
|
233
|
-
expect((root.querySelector('.ocw-csat') as HTMLElement).style.display).not.toBe('none'), `CSAT should show for state="${state}"`
|
|
234
|
-
}
|
|
235
|
-
})
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
// ── Select field type ─────────────────────────────────────────────────────────
|
|
239
|
-
|
|
240
|
-
describe('Renderer — select field in form actions', () => {
|
|
241
|
-
it('renders a <select> element for select-type fields', () => {
|
|
242
|
-
const root = document.createElement('div')
|
|
243
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
|
|
244
|
-
const store = new ChatStore(asUserId(me))
|
|
245
|
-
const r = new Renderer(root, me, h)
|
|
246
|
-
store.apply(openedConv())
|
|
247
|
-
store.apply({
|
|
248
|
-
type: 'manifest', conversationId: cid, version: 1, actions: [
|
|
249
|
-
{
|
|
250
|
-
id: asActionId('book'), label: 'Book Room', audience: 'guest', surface: 'toolbar',
|
|
251
|
-
input: [
|
|
252
|
-
{ name: 'room_type', label: 'Room Type', type: 'select', required: true, options: ['Standard', 'Deluxe', 'Suite'] },
|
|
253
|
-
],
|
|
254
|
-
},
|
|
255
|
-
],
|
|
256
|
-
})
|
|
257
|
-
r.render(store)
|
|
258
|
-
;(root.querySelector('.ocw-chip') as HTMLButtonElement).click()
|
|
259
|
-
|
|
260
|
-
const sel = root.querySelector('.ocw-form-input') as HTMLSelectElement
|
|
261
|
-
expect(sel.tagName).toBe('SELECT')
|
|
262
|
-
const options = Array.from(sel.options).map(o => o.value)
|
|
263
|
-
expect(options).toEqual(expect.arrayContaining(['Standard', 'Deluxe', 'Suite']))
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
it('select field invokes with chosen value on submit', () => {
|
|
267
|
-
const root = document.createElement('div')
|
|
268
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
|
|
269
|
-
const store = new ChatStore(asUserId(me))
|
|
270
|
-
const r = new Renderer(root, me, h)
|
|
271
|
-
store.apply(openedConv())
|
|
272
|
-
store.apply({
|
|
273
|
-
type: 'manifest', conversationId: cid, version: 1, actions: [
|
|
274
|
-
{
|
|
275
|
-
id: asActionId('tier'), label: 'Choose Tier', audience: 'guest', surface: 'toolbar',
|
|
276
|
-
input: [{ name: 'tier', label: 'Tier', type: 'select', required: true, options: ['Basic', 'Pro', 'Enterprise'] }],
|
|
277
|
-
},
|
|
278
|
-
],
|
|
279
|
-
})
|
|
280
|
-
r.render(store)
|
|
281
|
-
;(root.querySelector('.ocw-chip') as HTMLButtonElement).click()
|
|
282
|
-
|
|
283
|
-
const sel = root.querySelector('.ocw-form-input') as HTMLSelectElement
|
|
284
|
-
sel.value = 'Pro'
|
|
285
|
-
;(root.querySelector('.ocw-form-submit') as HTMLButtonElement).click()
|
|
286
|
-
expect(h.onInvoke).toHaveBeenCalledWith('tier', { tier: 'Pro' })
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
it('mixes select and text fields in the same form', () => {
|
|
290
|
-
const root = document.createElement('div')
|
|
291
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
|
|
292
|
-
const store = new ChatStore(asUserId(me))
|
|
293
|
-
const r = new Renderer(root, me, h)
|
|
294
|
-
store.apply(openedConv())
|
|
295
|
-
store.apply({
|
|
296
|
-
type: 'manifest', conversationId: cid, version: 1, actions: [
|
|
297
|
-
{
|
|
298
|
-
id: asActionId('booking'), label: 'Book', audience: 'guest', surface: 'toolbar',
|
|
299
|
-
input: [
|
|
300
|
-
{ name: 'type', label: 'Type', type: 'select', required: true, options: ['A', 'B'] },
|
|
301
|
-
{ name: 'note', label: 'Note', type: 'text', required: false },
|
|
302
|
-
],
|
|
303
|
-
},
|
|
304
|
-
],
|
|
305
|
-
})
|
|
306
|
-
r.render(store)
|
|
307
|
-
;(root.querySelector('.ocw-chip') as HTMLButtonElement).click()
|
|
308
|
-
|
|
309
|
-
const formInputs = root.querySelectorAll('.ocw-form-input')
|
|
310
|
-
expect(formInputs.length).toBe(2)
|
|
311
|
-
expect(formInputs[0]!.tagName).toBe('SELECT')
|
|
312
|
-
expect(formInputs[1]!.tagName).toBe('INPUT')
|
|
313
|
-
})
|
|
314
|
-
})
|
|
315
|
-
|
|
316
|
-
// ── History / load-more ───────────────────────────────────────────────────────
|
|
317
|
-
|
|
318
|
-
describe('Renderer — load-more history', () => {
|
|
319
|
-
it('shows load-more button when store.hasMoreHistory and onLoadMore provided', () => {
|
|
320
|
-
const root = document.createElement('div')
|
|
321
|
-
const onLoadMore = vi.fn()
|
|
322
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onLoadMore }
|
|
323
|
-
const store = new ChatStore(asUserId(me))
|
|
324
|
-
const r = new Renderer(root, me, h)
|
|
325
|
-
store.apply(openedConv())
|
|
326
|
-
// Fake hasMoreHistory by injecting a synthetic history frame
|
|
327
|
-
store.apply({ type: 'history', conversationId: cid, messages: [], hasMore: true })
|
|
328
|
-
r.render(store)
|
|
329
|
-
const btn = root.querySelector('.ocw-load-more') as HTMLButtonElement
|
|
330
|
-
expect(btn).not.toBeNull()
|
|
331
|
-
btn.click()
|
|
332
|
-
expect(onLoadMore).toHaveBeenCalledTimes(1)
|
|
333
|
-
})
|
|
334
|
-
|
|
335
|
-
it('hides load-more button when hasMoreHistory is false', () => {
|
|
336
|
-
const root = document.createElement('div')
|
|
337
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onLoadMore: vi.fn() }
|
|
338
|
-
const store = new ChatStore(asUserId(me))
|
|
339
|
-
const r = new Renderer(root, me, h)
|
|
340
|
-
store.apply(openedConv())
|
|
341
|
-
store.apply({ type: 'history', conversationId: cid, messages: [], hasMore: false })
|
|
342
|
-
r.render(store)
|
|
343
|
-
expect(root.querySelector('.ocw-load-more')).toBeNull()
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
it('hides load-more button when onLoadMore not provided', () => {
|
|
347
|
-
const root = document.createElement('div')
|
|
348
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
|
|
349
|
-
const store = new ChatStore(asUserId(me))
|
|
350
|
-
const r = new Renderer(root, me, h)
|
|
351
|
-
store.apply(openedConv())
|
|
352
|
-
store.apply({ type: 'history', conversationId: cid, messages: [], hasMore: true })
|
|
353
|
-
r.render(store)
|
|
354
|
-
expect(root.querySelector('.ocw-load-more')).toBeNull()
|
|
355
|
-
})
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
// ── Edit / delete context menu ────────────────────────────────────────────────
|
|
359
|
-
|
|
360
|
-
describe('Renderer — edit/delete context menu', () => {
|
|
361
|
-
it('shows edit and delete buttons on own non-deleted messages', () => {
|
|
362
|
-
const root = document.createElement('div')
|
|
363
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onEdit: vi.fn(), onDelete: vi.fn() }
|
|
364
|
-
const store = new ChatStore(asUserId(me))
|
|
365
|
-
const r = new Renderer(root, me, h)
|
|
366
|
-
store.apply(openedConv())
|
|
367
|
-
store.apply({ type: 'message', message: { id: asMessageId('m1'), conversationId: cid, seq: 1, senderId: asUserId(me), senderRole: 'guest', content: { kind: 'text', text: 'my msg' }, ts: 1 } })
|
|
368
|
-
r.render(store)
|
|
369
|
-
const menu = root.querySelector('.ocw-msg-menu')
|
|
370
|
-
expect(menu).not.toBeNull()
|
|
371
|
-
expect(menu!.querySelectorAll('button').length).toBe(2)
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
it('delete button calls onDelete with message id', () => {
|
|
375
|
-
const root = document.createElement('div')
|
|
376
|
-
const onDelete = vi.fn()
|
|
377
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onDelete }
|
|
378
|
-
const store = new ChatStore(asUserId(me))
|
|
379
|
-
const r = new Renderer(root, me, h)
|
|
380
|
-
store.apply(openedConv())
|
|
381
|
-
store.apply({ type: 'message', message: { id: asMessageId('m2'), conversationId: cid, seq: 2, senderId: asUserId(me), senderRole: 'guest', content: { kind: 'text', text: 'bye' }, ts: 2 } })
|
|
382
|
-
r.render(store)
|
|
383
|
-
const delBtn = root.querySelector('.ocw-msg-menu .del') as HTMLButtonElement
|
|
384
|
-
expect(delBtn).not.toBeNull()
|
|
385
|
-
delBtn.click()
|
|
386
|
-
expect(onDelete).toHaveBeenCalledWith('m2')
|
|
387
|
-
})
|
|
388
|
-
|
|
389
|
-
it('does not show context menu on other users messages', () => {
|
|
390
|
-
const root = document.createElement('div')
|
|
391
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onEdit: vi.fn(), onDelete: vi.fn() }
|
|
392
|
-
const store = new ChatStore(asUserId(me))
|
|
393
|
-
const r = new Renderer(root, me, h)
|
|
394
|
-
store.apply(openedConv())
|
|
395
|
-
store.apply({ type: 'message', message: { id: asMessageId('m3'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'hi' }, ts: 1 } })
|
|
396
|
-
r.render(store)
|
|
397
|
-
expect(root.querySelector('.ocw-msg-menu')).toBeNull()
|
|
398
|
-
})
|
|
399
|
-
|
|
400
|
-
it('does not show context menu when handlers not provided', () => {
|
|
401
|
-
const root = document.createElement('div')
|
|
402
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
|
|
403
|
-
const store = new ChatStore(asUserId(me))
|
|
404
|
-
const r = new Renderer(root, me, h)
|
|
405
|
-
store.apply(openedConv())
|
|
406
|
-
store.apply({ type: 'message', message: { id: asMessageId('m4'), conversationId: cid, seq: 1, senderId: asUserId(me), senderRole: 'guest', content: { kind: 'text', text: 'hi' }, ts: 1 } })
|
|
407
|
-
r.render(store)
|
|
408
|
-
expect(root.querySelector('.ocw-msg-menu')).toBeNull()
|
|
409
|
-
})
|
|
410
|
-
})
|
|
411
|
-
|
|
412
|
-
// ── Connection status indicator ───────────────────────────────────────────────
|
|
413
|
-
|
|
414
|
-
describe('Renderer — connection status', () => {
|
|
415
|
-
it('setConnStatus("connecting") shows connecting label', () => {
|
|
416
|
-
const root = document.createElement('div')
|
|
417
|
-
const r = new Renderer(root, me, { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() })
|
|
418
|
-
r.setConnStatus('connecting')
|
|
419
|
-
const el = root.querySelector('.ocw-conn-status') as HTMLElement
|
|
420
|
-
expect(el.style.display).not.toBe('none')
|
|
421
|
-
expect(el.textContent).toContain('connecting')
|
|
422
|
-
})
|
|
423
|
-
|
|
424
|
-
it('setConnStatus("reconnecting") shows warn class', () => {
|
|
425
|
-
const root = document.createElement('div')
|
|
426
|
-
const r = new Renderer(root, me, { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() })
|
|
427
|
-
r.setConnStatus('reconnecting')
|
|
428
|
-
const el = root.querySelector('.ocw-conn-status') as HTMLElement
|
|
429
|
-
expect(el.classList.contains('warn')).toBe(true)
|
|
430
|
-
expect(el.textContent).toContain('reconnecting')
|
|
431
|
-
})
|
|
432
|
-
|
|
433
|
-
it('setConnStatus("open") hides the indicator', () => {
|
|
434
|
-
const root = document.createElement('div')
|
|
435
|
-
const r = new Renderer(root, me, { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() })
|
|
436
|
-
r.setConnStatus('connecting')
|
|
437
|
-
r.setConnStatus('open')
|
|
438
|
-
const el = root.querySelector('.ocw-conn-status') as HTMLElement
|
|
439
|
-
expect(el.style.display).toBe('none')
|
|
440
|
-
})
|
|
441
|
-
})
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
asUserId, asConversationId, asMessageId,
|
|
4
|
-
type ServerFrame, type Message,
|
|
5
|
-
} from '../protocol/index.js'
|
|
6
|
-
import { ChatStore } from '../store.js'
|
|
7
|
-
|
|
8
|
-
const me = asUserId('alice')
|
|
9
|
-
const cid = asConversationId('c1')
|
|
10
|
-
|
|
11
|
-
function srvMsg(seq: number, from: string, text: string): Message {
|
|
12
|
-
return { id: asMessageId(`s${seq}`), conversationId: cid, seq, senderId: asUserId(from), senderRole: from === 'alice' ? 'guest' : 'agent', content: { kind: 'text', text }, ts: seq }
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
describe('ChatStore', () => {
|
|
16
|
-
it('reconciles an optimistic send on ack (rekey, seq, status)', () => {
|
|
17
|
-
const s = new ChatStore(me)
|
|
18
|
-
s.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: me, participants: [me], state: 'open', lastSeq: 0, createdAt: 0, updatedAt: 0 } })
|
|
19
|
-
s.addOptimistic('cm1', { kind: 'text', text: 'hi' })
|
|
20
|
-
expect(s.messages()[0]!.status).toBe('pending')
|
|
21
|
-
s.apply({ type: 'ack', clientMsgId: 'cm1', messageId: asMessageId('s1'), seq: 1, ts: 10 })
|
|
22
|
-
const m = s.messages()[0]!
|
|
23
|
-
expect(m.id).toBe('s1')
|
|
24
|
-
expect(m.seq).toBe(1)
|
|
25
|
-
expect(m.status).toBe('sent')
|
|
26
|
-
expect(s.messages()).toHaveLength(1) // not duplicated
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('dedups the same message arriving live and via sync', () => {
|
|
30
|
-
const s = new ChatStore(me)
|
|
31
|
-
s.apply({ type: 'message', message: srvMsg(2, 'bob', 'yo') })
|
|
32
|
-
s.apply({ type: 'sync', conversationId: cid, messages: [srvMsg(1, 'bob', 'first'), srvMsg(2, 'bob', 'yo')] })
|
|
33
|
-
expect(s.messages().map(m => m.seq)).toEqual([1, 2]) // seq-ordered, no dup of seq 2
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it('progresses own-message status delivered → read', () => {
|
|
37
|
-
const s = new ChatStore(me)
|
|
38
|
-
s.addOptimistic('cm1', { kind: 'text', text: 'hi' })
|
|
39
|
-
s.apply({ type: 'ack', clientMsgId: 'cm1', messageId: asMessageId('s1'), seq: 1, ts: 1 })
|
|
40
|
-
s.apply({ type: 'delivered', conversationId: cid, seq: 1, to: me })
|
|
41
|
-
expect(s.messages()[0]!.status).toBe('delivered')
|
|
42
|
-
s.apply({ type: 'read', conversationId: cid, seq: 1, by: asUserId('bob') })
|
|
43
|
-
expect(s.messages()[0]!.status).toBe('read')
|
|
44
|
-
expect(s.lastReadByOthers).toBe(1)
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('tracks typing from others only', () => {
|
|
48
|
-
const s = new ChatStore(me)
|
|
49
|
-
s.apply({ type: 'typing', conversationId: cid, userId: asUserId('bob'), isTyping: true })
|
|
50
|
-
s.apply({ type: 'typing', conversationId: cid, userId: me, isTyping: true }) // self ignored
|
|
51
|
-
expect([...s.typing]).toEqual(['bob'])
|
|
52
|
-
s.apply({ type: 'typing', conversationId: cid, userId: asUserId('bob'), isTyping: false })
|
|
53
|
-
expect(s.typing.size).toBe(0)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('toggles reactions and applies edit/delete', () => {
|
|
57
|
-
const s = new ChatStore(me)
|
|
58
|
-
s.apply({ type: 'message', message: srvMsg(1, 'bob', 'hi') })
|
|
59
|
-
s.apply({ type: 'reaction', conversationId: cid, messageId: asMessageId('s1'), emoji: '👍', by: me, removed: false })
|
|
60
|
-
expect(s.messages()[0]!.reactions).toEqual({ '👍': [me] })
|
|
61
|
-
s.apply({ type: 'reaction', conversationId: cid, messageId: asMessageId('s1'), emoji: '👍', by: me, removed: true })
|
|
62
|
-
expect(s.messages()[0]!.reactions).toEqual({})
|
|
63
|
-
s.apply({ type: 'edited', conversationId: cid, messageId: asMessageId('s1'), content: { kind: 'text', text: 'edited' }, editedAt: 5 })
|
|
64
|
-
expect(s.messages()[0]!.content).toEqual({ kind: 'text', text: 'edited' })
|
|
65
|
-
s.apply({ type: 'deleted', conversationId: cid, messageId: asMessageId('s1'), ts: 6 })
|
|
66
|
-
expect(s.messages()[0]!.deletedAt).toBe(6)
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('filters visible actions by conversation state', () => {
|
|
70
|
-
const s = new ChatStore(me)
|
|
71
|
-
s.apply({ type: 'manifest', conversationId: cid, version: 1, actions: [
|
|
72
|
-
{ id: asMessageId('a1') as never, label: 'Always', audience: 'guest', surface: 'toolbar' },
|
|
73
|
-
{ id: asMessageId('a2') as never, label: 'Closed only', audience: 'guest', surface: 'toolbar', availableInStates: ['closed'] },
|
|
74
|
-
] })
|
|
75
|
-
s.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: me, participants: [me], state: 'open', lastSeq: 0, createdAt: 0, updatedAt: 0 } })
|
|
76
|
-
expect(s.visibleActions().map(a => a.label)).toEqual(['Always'])
|
|
77
|
-
s.apply({ type: 'state', conversationId: cid, state: 'closed' })
|
|
78
|
-
expect(s.visibleActions().map(a => a.label)).toEqual(['Always', 'Closed only'])
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it('reports highest seq for the sync cursor', () => {
|
|
82
|
-
const s = new ChatStore(me)
|
|
83
|
-
s.apply({ type: 'sync', conversationId: cid, messages: [srvMsg(3, 'bob', 'c'), srvMsg(7, 'bob', 'g')] })
|
|
84
|
-
expect(s.highestSeq()).toBe(7)
|
|
85
|
-
})
|
|
86
|
-
})
|