@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.
Files changed (51) hide show
  1. package/README.md +138 -12
  2. package/dist/connection.d.ts +48 -0
  3. package/dist/crypto.d.ts +69 -0
  4. package/dist/e2e.d.ts +75 -0
  5. package/dist/history.d.ts +14 -0
  6. package/dist/index.d.ts +60 -0
  7. package/dist/index.js +2638 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/outbox.d.ts +20 -0
  10. package/dist/protocol/actions.d.ts +98 -0
  11. package/dist/protocol/codec.d.ts +12 -0
  12. package/dist/protocol/entities.d.ts +109 -0
  13. package/dist/protocol/frames.d.ts +248 -0
  14. package/dist/protocol/ids.d.ts +22 -0
  15. package/dist/protocol/index.d.ts +5 -0
  16. package/dist/react.d.ts +124 -0
  17. package/dist/react.js +143 -0
  18. package/dist/react.js.map +1 -0
  19. package/dist/renderer.d.ts +163 -0
  20. package/dist/store.d.ts +48 -0
  21. package/package.json +31 -2
  22. package/build-preview.js +0 -136
  23. package/index.html +0 -37
  24. package/src/__tests__/chatlist.test.ts +0 -133
  25. package/src/__tests__/connection.test.ts +0 -163
  26. package/src/__tests__/crypto.test.ts +0 -28
  27. package/src/__tests__/history.test.ts +0 -91
  28. package/src/__tests__/ime.test.ts +0 -93
  29. package/src/__tests__/render.test.ts +0 -58
  30. package/src/__tests__/render_new.test.ts +0 -441
  31. package/src/__tests__/store.test.ts +0 -86
  32. package/src/__tests__/x3dh.test.ts +0 -204
  33. package/src/connection.ts +0 -133
  34. package/src/crypto.ts +0 -252
  35. package/src/e2e.ts +0 -161
  36. package/src/history.ts +0 -43
  37. package/src/index.ts +0 -380
  38. package/src/outbox.ts +0 -58
  39. package/src/protocol/actions.ts +0 -114
  40. package/src/protocol/codec.ts +0 -35
  41. package/src/protocol/entities.ts +0 -104
  42. package/src/protocol/frames.ts +0 -86
  43. package/src/protocol/ids.ts +0 -27
  44. package/src/protocol/index.ts +0 -5
  45. package/src/react.tsx +0 -37
  46. package/src/renderer.ts +0 -906
  47. package/src/store.ts +0 -207
  48. package/tsconfig.json +0 -33
  49. package/vercel.json +0 -22
  50. package/vite.config.ts +0 -26
  51. 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
- })