@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,133 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
3
|
-
import { asUserId, asConversationId } from '../protocol/index.js'
|
|
4
|
-
import { ChatStore } from '../store.js'
|
|
5
|
-
import { Renderer, type ChatListEntry } 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
|
-
describe('Renderer — multi-subject chat list', () => {
|
|
22
|
-
it('does not show the chat-list button when onListChats is not provided', () => {
|
|
23
|
-
const root = document.createElement('div')
|
|
24
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
|
|
25
|
-
const store = new ChatStore(asUserId(me))
|
|
26
|
-
const r = new Renderer(root, me, h)
|
|
27
|
-
store.apply(openedConv())
|
|
28
|
-
r.render(store)
|
|
29
|
-
expect(root.querySelector('.ocw-chatlist-btn')).toBeNull()
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('shows the chat-list button when onListChats is provided', () => {
|
|
33
|
-
const root = document.createElement('div')
|
|
34
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onListChats: vi.fn() }
|
|
35
|
-
const store = new ChatStore(asUserId(me))
|
|
36
|
-
const r = new Renderer(root, me, h)
|
|
37
|
-
store.apply(openedConv())
|
|
38
|
-
r.render(store)
|
|
39
|
-
expect(root.querySelector('.ocw-chatlist-btn')).not.toBeNull()
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('opens the panel and renders one item per conversation, newest first as returned by the handler', async () => {
|
|
43
|
-
const entries: ChatListEntry[] = [
|
|
44
|
-
{ id: 'c_car_a', subjectId: 'car_a', subjectTitle: '2019 Toyota Camry', state: 'open', updatedAt: Date.now() },
|
|
45
|
-
{ id: 'c_car_b', subjectId: 'car_b', subjectTitle: '2021 Honda Civic', state: 'resolved', updatedAt: Date.now() - 1000 },
|
|
46
|
-
]
|
|
47
|
-
const onListChats = vi.fn().mockResolvedValue(entries)
|
|
48
|
-
const root = document.createElement('div')
|
|
49
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onListChats }
|
|
50
|
-
const store = new ChatStore(asUserId(me))
|
|
51
|
-
const r = new Renderer(root, me, h)
|
|
52
|
-
store.apply(openedConv())
|
|
53
|
-
r.render(store)
|
|
54
|
-
|
|
55
|
-
const btn = root.querySelector('.ocw-chatlist-btn') as HTMLButtonElement
|
|
56
|
-
btn.click()
|
|
57
|
-
expect(onListChats).toHaveBeenCalledTimes(1)
|
|
58
|
-
await Promise.resolve(); await Promise.resolve() // flush the .then()
|
|
59
|
-
|
|
60
|
-
expect(root.querySelector('.ocw-chatlist-panel')?.classList.contains('open')).toBe(true)
|
|
61
|
-
const items = root.querySelectorAll('.ocw-chatlist-item')
|
|
62
|
-
expect(items.length).toBe(2)
|
|
63
|
-
expect(items[0]!.textContent).toContain('2019 Toyota Camry')
|
|
64
|
-
expect(items[1]!.textContent).toContain('2021 Honda Civic')
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('shows a general-enquiry label for an entry with no subjectTitle', async () => {
|
|
68
|
-
const entries: ChatListEntry[] = [
|
|
69
|
-
{ id: 'c_general', state: 'open', updatedAt: Date.now() },
|
|
70
|
-
]
|
|
71
|
-
const onListChats = vi.fn().mockResolvedValue(entries)
|
|
72
|
-
const root = document.createElement('div')
|
|
73
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onListChats }
|
|
74
|
-
const store = new ChatStore(asUserId(me))
|
|
75
|
-
const r = new Renderer(root, me, h)
|
|
76
|
-
store.apply(openedConv())
|
|
77
|
-
r.render(store)
|
|
78
|
-
|
|
79
|
-
;(root.querySelector('.ocw-chatlist-btn') as HTMLButtonElement).click()
|
|
80
|
-
await Promise.resolve(); await Promise.resolve()
|
|
81
|
-
|
|
82
|
-
expect(root.querySelector('.ocw-chatlist-item')?.textContent).toContain('General enquiry')
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('shows an empty state when the guest has no conversations yet', async () => {
|
|
86
|
-
const onListChats = vi.fn().mockResolvedValue([])
|
|
87
|
-
const root = document.createElement('div')
|
|
88
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onListChats }
|
|
89
|
-
const store = new ChatStore(asUserId(me))
|
|
90
|
-
const r = new Renderer(root, me, h)
|
|
91
|
-
store.apply(openedConv())
|
|
92
|
-
r.render(store)
|
|
93
|
-
|
|
94
|
-
;(root.querySelector('.ocw-chatlist-btn') as HTMLButtonElement).click()
|
|
95
|
-
await Promise.resolve(); await Promise.resolve()
|
|
96
|
-
|
|
97
|
-
expect(root.querySelector('.ocw-chatlist-empty')?.textContent).toMatch(/no conversations/i)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('calls onSwitchChat with the clicked entry and closes the panel', async () => {
|
|
101
|
-
const entry: ChatListEntry = { id: 'c_car_a', subjectId: 'car_a', subjectTitle: '2019 Toyota Camry', state: 'open', updatedAt: Date.now() }
|
|
102
|
-
const onListChats = vi.fn().mockResolvedValue([entry])
|
|
103
|
-
const onSwitchChat = vi.fn()
|
|
104
|
-
const root = document.createElement('div')
|
|
105
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onListChats, onSwitchChat }
|
|
106
|
-
const store = new ChatStore(asUserId(me))
|
|
107
|
-
const r = new Renderer(root, me, h)
|
|
108
|
-
store.apply(openedConv())
|
|
109
|
-
r.render(store)
|
|
110
|
-
|
|
111
|
-
;(root.querySelector('.ocw-chatlist-btn') as HTMLButtonElement).click()
|
|
112
|
-
await Promise.resolve(); await Promise.resolve()
|
|
113
|
-
|
|
114
|
-
;(root.querySelector('.ocw-chatlist-item') as HTMLButtonElement).click()
|
|
115
|
-
expect(onSwitchChat).toHaveBeenCalledWith(entry)
|
|
116
|
-
expect(root.querySelector('.ocw-chatlist-panel')?.classList.contains('open')).toBe(false)
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
it('shows a friendly error state if the handler rejects', async () => {
|
|
120
|
-
const onListChats = vi.fn().mockRejectedValue(new Error('network down'))
|
|
121
|
-
const root = document.createElement('div')
|
|
122
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onListChats }
|
|
123
|
-
const store = new ChatStore(asUserId(me))
|
|
124
|
-
const r = new Renderer(root, me, h)
|
|
125
|
-
store.apply(openedConv())
|
|
126
|
-
r.render(store)
|
|
127
|
-
|
|
128
|
-
;(root.querySelector('.ocw-chatlist-btn') as HTMLButtonElement).click()
|
|
129
|
-
await Promise.resolve(); await Promise.resolve()
|
|
130
|
-
|
|
131
|
-
expect(root.querySelector('.ocw-chatlist-empty')?.textContent).toMatch(/could not load/i)
|
|
132
|
-
})
|
|
133
|
-
})
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { encodeFrame, decodeFrame, type ClientFrame, type ServerFrame, asConversationId } from '../protocol/index.js'
|
|
3
|
-
import { ConnectionManager, type SocketLike } from '../connection.js'
|
|
4
|
-
|
|
5
|
-
class FakeSocket implements SocketLike {
|
|
6
|
-
binaryType = 'blob'
|
|
7
|
-
sent: ClientFrame[] = []
|
|
8
|
-
onopen: (() => void) | null = null
|
|
9
|
-
onclose: (() => void) | null = null
|
|
10
|
-
onerror: (() => void) | null = null
|
|
11
|
-
onmessage: ((ev: { data: ArrayBuffer }) => void) | null = null
|
|
12
|
-
closed = false
|
|
13
|
-
send(data: Uint8Array): void { this.sent.push(decodeFrame(data) as ClientFrame) }
|
|
14
|
-
close(): void { this.closed = true; this.onclose?.() }
|
|
15
|
-
// test helpers
|
|
16
|
-
open(): void { this.onopen?.() }
|
|
17
|
-
deliver(frame: ServerFrame): void {
|
|
18
|
-
const u8 = encodeFrame(frame)
|
|
19
|
-
this.onmessage?.({ data: u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) })
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const open = { type: 'open', profileId: 'p1' as never, subjectId: 's1' as never } as Extract<ClientFrame, { type: 'open' }>
|
|
24
|
-
|
|
25
|
-
function setup(cursor = 0) {
|
|
26
|
-
const sockets: FakeSocket[] = []
|
|
27
|
-
const received: ServerFrame[] = []
|
|
28
|
-
const cm = new ConnectionManager({
|
|
29
|
-
url: 'ws://x', token: 'guest-alice', open,
|
|
30
|
-
onFrame: (f) => received.push(f),
|
|
31
|
-
getCursor: () => cursor,
|
|
32
|
-
socketFactory: () => { const s = new FakeSocket(); sockets.push(s); return s },
|
|
33
|
-
backoffBaseMs: 10, backoffMaxMs: 20,
|
|
34
|
-
})
|
|
35
|
-
return { cm, sockets, received }
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
describe('ConnectionManager', () => {
|
|
39
|
-
beforeEach(() => vi.useFakeTimers())
|
|
40
|
-
afterEach(() => vi.useRealTimers())
|
|
41
|
-
|
|
42
|
-
it('authenticates first, then opens and flushes the outbox after authed', () => {
|
|
43
|
-
const { cm, sockets } = setup()
|
|
44
|
-
cm.connect()
|
|
45
|
-
const s = sockets[0]!
|
|
46
|
-
// queue a send before auth completes — it must be buffered, not sent
|
|
47
|
-
cm.send({ type: 'send', conversationId: asConversationId('c1'), clientMsgId: 'm1', content: { kind: 'text', text: 'hi' } })
|
|
48
|
-
s.open()
|
|
49
|
-
expect(s.sent[0]).toMatchObject({ type: 'auth' })
|
|
50
|
-
expect(s.sent.some(f => f.type === 'send')).toBe(false) // not before authed
|
|
51
|
-
|
|
52
|
-
s.deliver({ type: 'authed', userId: 'alice' as never, connectionId: 'c' as never })
|
|
53
|
-
const types = s.sent.map(f => f.type)
|
|
54
|
-
expect(types).toContain('open') // open sent on authed
|
|
55
|
-
expect(types).toContain('send') // outbox flushed after
|
|
56
|
-
expect(types.indexOf('open')).toBeLessThan(types.indexOf('send'))
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('syncs from the cursor when a conversation is opened', () => {
|
|
60
|
-
const { cm, sockets } = setup(5)
|
|
61
|
-
cm.connect()
|
|
62
|
-
const s = sockets[0]!
|
|
63
|
-
s.open()
|
|
64
|
-
s.deliver({ type: 'authed', userId: 'alice' as never, connectionId: 'c' as never })
|
|
65
|
-
s.deliver({ type: 'opened', conversation: { id: asConversationId('c9'), tenantId: 't' as never, profileId: 'p1' as never, guestId: 'alice' as never, participants: [], state: 'open', lastSeq: 5, createdAt: 0, updatedAt: 0 } })
|
|
66
|
-
const sync = s.sent.find(f => f.type === 'sync')
|
|
67
|
-
expect(sync).toMatchObject({ type: 'sync', conversationId: 'c9', sinceSeq: 5 })
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('reconnects with backoff after an unexpected close', () => {
|
|
71
|
-
const { cm, sockets } = setup()
|
|
72
|
-
cm.connect()
|
|
73
|
-
sockets[0]!.open()
|
|
74
|
-
sockets[0]!.deliver({ type: 'authed', userId: 'a' as never, connectionId: 'c' as never })
|
|
75
|
-
sockets[0]!.onclose?.() // drop
|
|
76
|
-
expect(sockets).toHaveLength(1)
|
|
77
|
-
vi.advanceTimersByTime(50) // past backoff
|
|
78
|
-
expect(sockets.length).toBe(2) // reconnected with a new socket
|
|
79
|
-
sockets[1]!.open()
|
|
80
|
-
expect(sockets[1]!.sent[0]).toMatchObject({ type: 'auth' })
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('bounds the outbox so a long outage cannot grow memory unbounded', () => {
|
|
84
|
-
const sockets: FakeSocket[] = []
|
|
85
|
-
const cm = new ConnectionManager({
|
|
86
|
-
url: 'ws://x', token: 't', open,
|
|
87
|
-
onFrame: () => {}, getCursor: () => 0,
|
|
88
|
-
socketFactory: () => { const s = new FakeSocket(); sockets.push(s); return s },
|
|
89
|
-
maxOutbox: 2,
|
|
90
|
-
})
|
|
91
|
-
cm.connect()
|
|
92
|
-
const s = sockets[0]!
|
|
93
|
-
for (let i = 0; i < 5; i++) cm.send({ type: 'send', conversationId: asConversationId('c1'), clientMsgId: `m${i}`, content: { kind: 'text', text: 'x' } })
|
|
94
|
-
s.open()
|
|
95
|
-
s.deliver({ type: 'authed', userId: 'a' as never, connectionId: 'c' as never })
|
|
96
|
-
const sends = s.sent.filter(f => f.type === 'send')
|
|
97
|
-
expect(sends).toHaveLength(2) // capped
|
|
98
|
-
expect((sends[1] as { clientMsgId: string }).clientMsgId).toBe('m4') // newest kept
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('does not reconnect after an explicit close', () => {
|
|
102
|
-
const { cm, sockets } = setup()
|
|
103
|
-
cm.connect()
|
|
104
|
-
sockets[0]!.open()
|
|
105
|
-
cm.close()
|
|
106
|
-
vi.advanceTimersByTime(100)
|
|
107
|
-
expect(sockets).toHaveLength(1)
|
|
108
|
-
})
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
describe('ConnectionManager — status callbacks', () => {
|
|
112
|
-
beforeEach(() => { vi.useFakeTimers() })
|
|
113
|
-
afterEach(() => { vi.useRealTimers() })
|
|
114
|
-
|
|
115
|
-
it('emits "connecting" on first connect', () => {
|
|
116
|
-
const statuses: string[] = []
|
|
117
|
-
const sockets: FakeSocket[] = []
|
|
118
|
-
const cm = new ConnectionManager({
|
|
119
|
-
url: 'ws://x', token: 't', open,
|
|
120
|
-
getCursor: () => 0,
|
|
121
|
-
onFrame: () => {},
|
|
122
|
-
onStatusChange: (s) => statuses.push(s),
|
|
123
|
-
socketFactory: (url) => { const s = new FakeSocket(); sockets.push(s); return s },
|
|
124
|
-
})
|
|
125
|
-
cm.connect()
|
|
126
|
-
expect(statuses).toContain('connecting')
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
it('emits "open" after successful auth', () => {
|
|
130
|
-
const statuses: string[] = []
|
|
131
|
-
const sockets: FakeSocket[] = []
|
|
132
|
-
const cm = new ConnectionManager({
|
|
133
|
-
url: 'ws://x', token: 't', open,
|
|
134
|
-
getCursor: () => 0,
|
|
135
|
-
onFrame: () => {},
|
|
136
|
-
onStatusChange: (s) => statuses.push(s),
|
|
137
|
-
socketFactory: () => { const s = new FakeSocket(); sockets.push(s); return s },
|
|
138
|
-
})
|
|
139
|
-
cm.connect()
|
|
140
|
-
sockets[0]!.open()
|
|
141
|
-
sockets[0]!.deliver({ type: 'authed', userId: 'u1' as never })
|
|
142
|
-
expect(statuses).toContain('open')
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
it('emits "reconnecting" on second+ attempt', () => {
|
|
146
|
-
const statuses: string[] = []
|
|
147
|
-
const sockets: FakeSocket[] = []
|
|
148
|
-
const cm = new ConnectionManager({
|
|
149
|
-
url: 'ws://x', token: 't', open,
|
|
150
|
-
getCursor: () => 0,
|
|
151
|
-
onFrame: () => {},
|
|
152
|
-
onStatusChange: (s) => statuses.push(s),
|
|
153
|
-
backoffBaseMs: 10,
|
|
154
|
-
socketFactory: () => { const s = new FakeSocket(); sockets.push(s); return s },
|
|
155
|
-
})
|
|
156
|
-
cm.connect()
|
|
157
|
-
sockets[0]!.open()
|
|
158
|
-
sockets[0]!.deliver({ type: 'authed', userId: 'u1' as never })
|
|
159
|
-
sockets[0]!.close() // triggers reconnect
|
|
160
|
-
vi.advanceTimersByTime(100)
|
|
161
|
-
expect(statuses).toContain('reconnecting')
|
|
162
|
-
})
|
|
163
|
-
})
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { generateKeyPair, exportPublicKey, deriveSharedKey, encrypt, decrypt } from '../crypto.js'
|
|
3
|
-
|
|
4
|
-
describe('E2E crypto (ECDH + AES-GCM)', () => {
|
|
5
|
-
it('two parties derive the same key and round-trip a message', async () => {
|
|
6
|
-
const guest = await generateKeyPair()
|
|
7
|
-
const agent = await generateKeyPair()
|
|
8
|
-
const guestPub = await exportPublicKey(guest.publicKey)
|
|
9
|
-
const agentPub = await exportPublicKey(agent.publicKey)
|
|
10
|
-
|
|
11
|
-
// Guest encrypts to the agent; agent decrypts. Shared secret is symmetric.
|
|
12
|
-
const kGuest = await deriveSharedKey(guest.privateKey, agentPub)
|
|
13
|
-
const kAgent = await deriveSharedKey(agent.privateKey, guestPub)
|
|
14
|
-
|
|
15
|
-
const { ct, iv } = await encrypt(kGuest, 'is the price negotiable?')
|
|
16
|
-
expect(ct).not.toContain('negotiable') // actually encrypted
|
|
17
|
-
const plain = await decrypt(kAgent, ct, iv)
|
|
18
|
-
expect(plain).toBe('is the price negotiable?')
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
it('a wrong key cannot decrypt', async () => {
|
|
22
|
-
const a = await generateKeyPair(); const b = await generateKeyPair(); const c = await generateKeyPair()
|
|
23
|
-
const kAB = await deriveSharedKey(a.privateKey, await exportPublicKey(b.publicKey))
|
|
24
|
-
const kAC = await deriveSharedKey(a.privateKey, await exportPublicKey(c.publicKey))
|
|
25
|
-
const { ct, iv } = await encrypt(kAB, 'secret')
|
|
26
|
-
await expect(decrypt(kAC, ct, iv)).rejects.toBeTruthy()
|
|
27
|
-
})
|
|
28
|
-
})
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { asUserId, asConversationId, asMessageId } from '../protocol/index.js'
|
|
3
|
-
import { ChatStore } from '../store.js'
|
|
4
|
-
import { httpBaseFromWsUrl, restoreHistory } from '../history.js'
|
|
5
|
-
|
|
6
|
-
describe('httpBaseFromWsUrl', () => {
|
|
7
|
-
it('strips /ws and converts wss to https', () => {
|
|
8
|
-
expect(httpBaseFromWsUrl('wss://api.paramms.com/ws')).toBe('https://api.paramms.com')
|
|
9
|
-
})
|
|
10
|
-
it('strips /ws and converts ws to http', () => {
|
|
11
|
-
expect(httpBaseFromWsUrl('ws://localhost:3000/ws')).toBe('http://localhost:3000')
|
|
12
|
-
})
|
|
13
|
-
it('handles a trailing slash after /ws', () => {
|
|
14
|
-
expect(httpBaseFromWsUrl('wss://api.paramms.com/ws/')).toBe('https://api.paramms.com')
|
|
15
|
-
})
|
|
16
|
-
it('is a no-op when there is no /ws suffix', () => {
|
|
17
|
-
expect(httpBaseFromWsUrl('wss://api.paramms.com')).toBe('https://api.paramms.com')
|
|
18
|
-
})
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
describe('restoreHistory', () => {
|
|
22
|
-
const me = asUserId('g_test')
|
|
23
|
-
const cid = asConversationId('c1')
|
|
24
|
-
let fetchMock: ReturnType<typeof vi.fn>
|
|
25
|
-
|
|
26
|
-
beforeEach(() => {
|
|
27
|
-
fetchMock = vi.fn()
|
|
28
|
-
vi.stubGlobal('fetch', fetchMock)
|
|
29
|
-
})
|
|
30
|
-
afterEach(() => { vi.unstubAllGlobals() })
|
|
31
|
-
|
|
32
|
-
function fakeRenderer() {
|
|
33
|
-
return { render: vi.fn() } as unknown as import('../renderer.js').Renderer
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
it('fetches the right URL with the auth header and applies messages to the store', async () => {
|
|
37
|
-
fetchMock.mockResolvedValue({
|
|
38
|
-
ok: true,
|
|
39
|
-
json: async () => ({
|
|
40
|
-
messages: [
|
|
41
|
-
{ id: asMessageId('m1'), conversationId: cid, seq: 1, senderId: me, senderRole: 'guest', content: { kind: 'text', text: 'hi' }, ts: 1 },
|
|
42
|
-
],
|
|
43
|
-
}),
|
|
44
|
-
})
|
|
45
|
-
const store = new ChatStore(me)
|
|
46
|
-
const renderer = fakeRenderer()
|
|
47
|
-
|
|
48
|
-
await restoreHistory('wss://api.paramms.com/ws', 'g_test', cid, store, renderer)
|
|
49
|
-
|
|
50
|
-
expect(fetchMock).toHaveBeenCalledWith(
|
|
51
|
-
'https://api.paramms.com/conversations/c1/history',
|
|
52
|
-
{ headers: { authorization: 'Bearer g_test' } },
|
|
53
|
-
)
|
|
54
|
-
expect(store.messages()).toHaveLength(1)
|
|
55
|
-
expect(store.messages()[0]!.content).toEqual({ kind: 'text', text: 'hi' })
|
|
56
|
-
expect(renderer.render).toHaveBeenCalledWith(store)
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('does nothing (no render) when the response has no messages', async () => {
|
|
60
|
-
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ messages: [] }) })
|
|
61
|
-
const store = new ChatStore(me)
|
|
62
|
-
const renderer = fakeRenderer()
|
|
63
|
-
await restoreHistory('wss://api.paramms.com/ws', 'g_test', cid, store, renderer)
|
|
64
|
-
expect(store.messages()).toHaveLength(0)
|
|
65
|
-
expect(renderer.render).not.toHaveBeenCalled()
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('does nothing when the response is not ok (e.g. 403)', async () => {
|
|
69
|
-
fetchMock.mockResolvedValue({ ok: false, status: 403, json: async () => ({ error: 'not a participant' }) })
|
|
70
|
-
const store = new ChatStore(me)
|
|
71
|
-
const renderer = fakeRenderer()
|
|
72
|
-
await restoreHistory('wss://api.paramms.com/ws', 'g_test', cid, store, renderer)
|
|
73
|
-
expect(store.messages()).toHaveLength(0)
|
|
74
|
-
expect(renderer.render).not.toHaveBeenCalled()
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it('swallows network errors without throwing', async () => {
|
|
78
|
-
fetchMock.mockRejectedValue(new Error('network down'))
|
|
79
|
-
const store = new ChatStore(me)
|
|
80
|
-
const renderer = fakeRenderer()
|
|
81
|
-
await expect(restoreHistory('wss://api.paramms.com/ws', 'g_test', cid, store, renderer)).resolves.toBeUndefined()
|
|
82
|
-
expect(renderer.render).not.toHaveBeenCalled()
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('swallows a malformed JSON response without throwing', async () => {
|
|
86
|
-
fetchMock.mockResolvedValue({ ok: true, json: async () => { throw new Error('bad json') } })
|
|
87
|
-
const store = new ChatStore(me)
|
|
88
|
-
const renderer = fakeRenderer()
|
|
89
|
-
await expect(restoreHistory('wss://api.paramms.com/ws', 'g_test', cid, store, renderer)).resolves.toBeUndefined()
|
|
90
|
-
})
|
|
91
|
-
})
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
3
|
-
import { asUserId, asConversationId } 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
|
-
function setup() {
|
|
22
|
-
const root = document.createElement('div')
|
|
23
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
|
|
24
|
-
const store = new ChatStore(asUserId(me))
|
|
25
|
-
const r = new Renderer(root, me, h)
|
|
26
|
-
store.apply(openedConv())
|
|
27
|
-
r.render(store)
|
|
28
|
-
const textarea = root.querySelector('textarea') as HTMLTextAreaElement
|
|
29
|
-
return { root, h, textarea }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe('Renderer — IME composition guard (CJK input)', () => {
|
|
33
|
-
it('does NOT send when Enter confirms an IME composition candidate', () => {
|
|
34
|
-
const { h, textarea } = setup()
|
|
35
|
-
textarea.value = '음식'
|
|
36
|
-
// Simulates the keydown fired by the IME when the user presses Enter to
|
|
37
|
-
// CONFIRM a composed Korean/Japanese/Chinese candidate — not to submit
|
|
38
|
-
// the message. isComposing: true is how browsers signal this.
|
|
39
|
-
const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
|
40
|
-
Object.defineProperty(ev, 'isComposing', { value: true })
|
|
41
|
-
textarea.dispatchEvent(ev)
|
|
42
|
-
expect(h.onSend).not.toHaveBeenCalled()
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('falls back to keyCode 229 for IMEs that do not set isComposing', () => {
|
|
46
|
-
const { h, textarea } = setup()
|
|
47
|
-
textarea.value = '음식'
|
|
48
|
-
const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
|
49
|
-
Object.defineProperty(ev, 'isComposing', { value: false })
|
|
50
|
-
Object.defineProperty(ev, 'keyCode', { value: 229 })
|
|
51
|
-
textarea.dispatchEvent(ev)
|
|
52
|
-
expect(h.onSend).not.toHaveBeenCalled()
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('still sends normally on a real (non-IME) Enter press', () => {
|
|
56
|
-
const { h, textarea } = setup()
|
|
57
|
-
textarea.value = 'hello'
|
|
58
|
-
const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
|
59
|
-
Object.defineProperty(ev, 'isComposing', { value: false })
|
|
60
|
-
textarea.dispatchEvent(ev)
|
|
61
|
-
expect(h.onSend).toHaveBeenCalledTimes(1)
|
|
62
|
-
expect(h.onSend).toHaveBeenCalledWith('hello')
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('does not produce a second send for the real Enter that follows an IME-confirm Enter', () => {
|
|
66
|
-
// Reproduces the exact reported bug: typing "음식" via IME, where Enter
|
|
67
|
-
// fires once to confirm the composition and again to submit — only the
|
|
68
|
-
// second, real Enter should trigger a send, and with the FULL text.
|
|
69
|
-
const { h, textarea } = setup()
|
|
70
|
-
|
|
71
|
-
textarea.value = '음식'
|
|
72
|
-
const composingEnter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
|
73
|
-
Object.defineProperty(composingEnter, 'isComposing', { value: true })
|
|
74
|
-
textarea.dispatchEvent(composingEnter)
|
|
75
|
-
|
|
76
|
-
// Real submit keystroke, composition has ended.
|
|
77
|
-
const realEnter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
|
78
|
-
Object.defineProperty(realEnter, 'isComposing', { value: false })
|
|
79
|
-
textarea.dispatchEvent(realEnter)
|
|
80
|
-
|
|
81
|
-
expect(h.onSend).toHaveBeenCalledTimes(1)
|
|
82
|
-
expect(h.onSend).toHaveBeenCalledWith('음식')
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('shift+Enter still inserts a newline (not a send) regardless of composition state', () => {
|
|
86
|
-
const { h, textarea } = setup()
|
|
87
|
-
textarea.value = 'line one'
|
|
88
|
-
const ev = new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true, cancelable: true })
|
|
89
|
-
Object.defineProperty(ev, 'isComposing', { value: false })
|
|
90
|
-
textarea.dispatchEvent(ev)
|
|
91
|
-
expect(h.onSend).not.toHaveBeenCalled()
|
|
92
|
-
})
|
|
93
|
-
})
|
|
@@ -1,58 +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
|
-
describe('Renderer (jsdom)', () => {
|
|
11
|
-
it('renders messages and actions, and wires send/invoke', () => {
|
|
12
|
-
const root = document.createElement('div')
|
|
13
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
|
|
14
|
-
const store = new ChatStore(asUserId(me))
|
|
15
|
-
const r = new Renderer(root, me, h)
|
|
16
|
-
|
|
17
|
-
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 } })
|
|
18
|
-
store.apply({ type: 'message', message: { id: asMessageId('s1'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'Welcome!' }, ts: 1 } })
|
|
19
|
-
store.apply({ type: 'manifest', conversationId: cid, version: 1, actions: [{ id: asActionId('ai'), label: 'Ask AI', audience: 'guest', surface: 'toolbar' }] })
|
|
20
|
-
r.render(store)
|
|
21
|
-
|
|
22
|
-
expect(root.querySelector('.ocw-scroll')!.textContent).toContain('Welcome!')
|
|
23
|
-
const actionBtn = root.querySelector('.ocw-chip') as HTMLButtonElement
|
|
24
|
-
expect(actionBtn.textContent).toBe('Ask AI')
|
|
25
|
-
|
|
26
|
-
actionBtn.click()
|
|
27
|
-
expect(h.onInvoke).toHaveBeenCalledWith('ai')
|
|
28
|
-
|
|
29
|
-
// read receipt fired for the other party's message
|
|
30
|
-
expect(h.onReadUpTo).toHaveBeenCalledWith(1)
|
|
31
|
-
|
|
32
|
-
const input = root.querySelector('.ocw-input textarea') as HTMLTextAreaElement
|
|
33
|
-
input.value = 'hello there'
|
|
34
|
-
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
|
|
35
|
-
expect(h.onSend).toHaveBeenCalledWith('hello there')
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('form action opens an inline form with typed inputs and invokes with values', () => {
|
|
39
|
-
const root = document.createElement('div')
|
|
40
|
-
const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
|
|
41
|
-
const store = new ChatStore(asUserId(me))
|
|
42
|
-
const r = new Renderer(root, me, h)
|
|
43
|
-
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 } })
|
|
44
|
-
store.apply({ type: 'manifest', conversationId: cid, version: 1, actions: [
|
|
45
|
-
{ id: asActionId('appointment'), label: 'Schedule', audience: 'guest', surface: 'toolbar',
|
|
46
|
-
input: [{ name: 'time', label: 'Time', type: 'date', required: true }] },
|
|
47
|
-
] })
|
|
48
|
-
r.render(store)
|
|
49
|
-
|
|
50
|
-
;(root.querySelector('.ocw-chip') as HTMLButtonElement).click()
|
|
51
|
-
const dateInput = root.querySelector('.ocw-form-input') as HTMLInputElement
|
|
52
|
-
expect(dateInput.type).toBe('datetime-local') // real date+time picker, not a prompt
|
|
53
|
-
dateInput.value = '2026-06-10T14:30'
|
|
54
|
-
;(root.querySelector('.ocw-form-submit') as HTMLButtonElement).click()
|
|
55
|
-
expect(h.onInvoke).toHaveBeenCalledWith('appointment', { time: '2026-06-10T14:30' })
|
|
56
|
-
expect(root.querySelector('.ocw-form')).toBeNull() // form closes after submit
|
|
57
|
-
})
|
|
58
|
-
})
|