@paramms/chat-widget 0.1.0

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 ADDED
@@ -0,0 +1,23 @@
1
+ # @paramms/chat-widget
2
+
3
+ Embeddable guest chat client. Pure `ChatStore` (state) + `ConnectionManager`
4
+ (ws/reconnect/outbox/cursor) + thin `Renderer`, wired by `mount()`.
5
+
6
+ `src/protocol/` is a vendored copy of the wire contract (only what the widget uses).
7
+
8
+ ## Run
9
+ ```bash
10
+ npm install
11
+ npm run dev # Vite demo at http://localhost:5173/ (expects the server on ws://localhost:3000)
12
+ npm run build # tsc → dist/ (library: importable by the dashboard)
13
+ npm run build:bundle # vite → standalone browser bundle
14
+ npm test # vitest (store, connection, jsdom render)
15
+ ```
16
+
17
+ ## Embed
18
+ ```html
19
+ <script type="module">
20
+ import { mount } from '@paramms/chat-widget'
21
+ mount({ el: document.getElementById('chat'), url: 'wss://your-host', profileId: 'p_hotel', subjectId: 'room-101' })
22
+ </script>
23
+ ```
@@ -0,0 +1,136 @@
1
+ // build-preview.js — generates dist/index.html after vite library build.
2
+ //
3
+ // Two modes, chosen by URL at load time (not build time):
4
+ // - relay.paramms.com → marketing/install-guide landing page
5
+ // - relay.paramms.com/?profileId=x → just the widget, in a contained card
6
+ // (this is the URL the dashboard's "Preview" link and shared test links
7
+ // use — should look exactly like the widget would look embedded on a
8
+ // real page, not a full-bleed app)
9
+ import { writeFileSync, mkdirSync } from 'node:fs'
10
+
11
+ mkdirSync('dist', { recursive: true })
12
+
13
+ const html = `<!doctype html>
14
+ <html lang="en">
15
+ <head>
16
+ <meta charset="utf-8" />
17
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
18
+ <title>Relay Chat Widget</title>
19
+ <style>
20
+ * { box-sizing: border-box; margin: 0; padding: 0; }
21
+ html, body { height: 100%; }
22
+ body {
23
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
24
+ min-height: 100vh;
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ padding: 24px;
29
+ }
30
+
31
+ /* Marketing mode (no profileId in URL): purple gradient + info column */
32
+ body.marketing {
33
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
34
+ }
35
+ .container {
36
+ max-width: 900px;
37
+ width: 100%;
38
+ display: flex;
39
+ gap: 48px;
40
+ align-items: flex-start;
41
+ }
42
+ .info { flex: 1; color: #fff; }
43
+ .info h1 { font-size: 36px; font-weight: 800; margin-bottom: 12px; }
44
+ .info p { font-size: 16px; opacity: .85; line-height: 1.6; margin-bottom: 24px; }
45
+ .snippet {
46
+ background: rgba(0,0,0,.3);
47
+ border-radius: 12px;
48
+ padding: 20px;
49
+ font-family: 'SF Mono', 'Fira Code', monospace;
50
+ font-size: 13px;
51
+ color: #e2e8f0;
52
+ line-height: 1.6;
53
+ white-space: pre;
54
+ }
55
+
56
+ /* Widget-only mode (?profileId=... present): bare contained card on a
57
+ neutral backdrop — what the widget actually looks like embedded on a
58
+ real page, just centered for easy viewing/sharing as a test link. */
59
+ body.widget-only { background: #e9eaee; }
60
+
61
+ /* Both modes use the same card shape for the widget itself */
62
+ .preview {
63
+ width: 390px;
64
+ height: 680px;
65
+ max-width: calc(100vw - 48px);
66
+ max-height: calc(100vh - 48px);
67
+ border-radius: 22px;
68
+ overflow: hidden;
69
+ box-shadow: 0 24px 60px rgba(0,0,0,.25);
70
+ background: #fff;
71
+ flex-shrink: 0;
72
+ }
73
+
74
+ #app { width: 100%; height: 100%; }
75
+ </style>
76
+ </head>
77
+ <body>
78
+ <div id="marketing-info" class="info" style="display:none">
79
+ <h1>Relay Chat Widget</h1>
80
+ <p>Add a real-time chat widget to any website in two lines of code.</p>
81
+ <div class="snippet">&lt;div id="chat"&gt;&lt;/div&gt;
82
+ &lt;script type="module"&gt;
83
+ import { mount } from 'https://relay.paramms.com/index.js'
84
+ mount({
85
+ el: document.getElementById('chat'),
86
+ url: 'wss://api.paramms.com/ws',
87
+ profileId: 'YOUR_PROFILE_ID',
88
+ })
89
+ &lt;/script&gt;</div>
90
+ </div>
91
+ <div id="layout"></div>
92
+ <script type="module">
93
+ import { mount } from './index.js'
94
+
95
+ const q = new URLSearchParams(location.search)
96
+ const profileId = q.get('profileId')
97
+ const isWidgetOnly = !!profileId
98
+
99
+ document.body.className = isWidgetOnly ? 'widget-only' : 'marketing'
100
+
101
+ const layout = document.getElementById('layout')
102
+ const preview = document.createElement('div')
103
+ preview.className = 'preview'
104
+ const app = document.createElement('div')
105
+ app.id = 'app'
106
+ preview.append(app)
107
+
108
+ if (isWidgetOnly) {
109
+ // Just the contained card, centered on a neutral backdrop.
110
+ layout.append(preview)
111
+ } else {
112
+ // Marketing landing page: info column + card preview, side by side.
113
+ const container = document.createElement('div')
114
+ container.className = 'container'
115
+ const info = document.getElementById('marketing-info')
116
+ info.style.display = ''
117
+ container.append(info, preview)
118
+ layout.append(container)
119
+ }
120
+
121
+ const wsUrl = q.get('url') || 'wss://api.paramms.com/ws'
122
+ const subjectId = q.get('subjectId') || undefined
123
+ mount({
124
+ el: app,
125
+ url: wsUrl,
126
+ profileId: profileId || 'p_hotel',
127
+ ...(subjectId ? { subjectId } : {}),
128
+ accent: q.get('accent') || '#4F63F5',
129
+ showChatList: q.get('chatList') !== 'off',
130
+ })
131
+ </script>
132
+ </body>
133
+ </html>`
134
+
135
+ writeFileSync('dist/index.html', html)
136
+ console.log('✓ dist/index.html written')
package/index.html ADDED
@@ -0,0 +1,37 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Relay widget</title>
7
+ <style>
8
+ body { margin:0; background:#e9ebef; font-family:system-ui,sans-serif; }
9
+ #app { width:390px; height:720px; margin:24px auto; border-radius:22px; overflow:hidden;
10
+ box-shadow:0 18px 50px rgba(0,0,0,.18); background:#fff; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <div id="app"></div>
15
+ <script type="module">
16
+ import { mount } from './src/index.ts'
17
+ const q = new URLSearchParams(location.search)
18
+ const subjectId = q.get('subjectId') || undefined
19
+ const title = q.get('title')
20
+ mount({
21
+ el: document.getElementById('app'),
22
+ url: q.get('url') || import.meta.env.VITE_SERVER_URL || 'ws://localhost:3000/ws',
23
+ profileId: q.get('profileId') || import.meta.env.VITE_PROFILE_ID || 'p_hotel',
24
+ ...(subjectId ? { subjectId } : {}),
25
+ accent: q.get('accent') || '#4F63F5',
26
+ ...(title ? { subject: {
27
+ ownerLabel: q.get('owner') || undefined,
28
+ title,
29
+ subtitle: q.get('subtitle') || '',
30
+ status: q.get('status') || '',
31
+ tags: (q.get('tags') || '').split(',').filter(Boolean),
32
+ } } : {}),
33
+ quickReplies: (q.get('quick') || '').split(',').filter(Boolean),
34
+ })
35
+ </script>
36
+ </body>
37
+ </html>
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@paramms/chat-widget",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "build": "vite build && tsc -p tsconfig.json --emitDeclarationOnly && node build-preview.js",
7
+ "typecheck": "tsc -p tsconfig.json --noEmit",
8
+ "test": "vitest run",
9
+ "dev": "vite",
10
+ "build:bundle": "vite build",
11
+ "preview": "vite preview"
12
+ },
13
+ "dependencies": {
14
+ "@msgpack/msgpack": "^3.0.0"
15
+ },
16
+ "devDependencies": {
17
+ "typescript": "^5.5.0",
18
+ "vite": "^5.4.0",
19
+ "vitest": "^2.1.0",
20
+ "jsdom": "^25.0.0",
21
+ "react": "^18.3.0",
22
+ "@types/react": "^18.3.0"
23
+ },
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./react": {
32
+ "types": "./dist/react.d.ts",
33
+ "default": "./dist/react.js"
34
+ }
35
+ },
36
+ "peerDependencies": {
37
+ "react": ">=18"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "react": {
41
+ "optional": true
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,133 @@
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
+ })
@@ -0,0 +1,163 @@
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
+ })
@@ -0,0 +1,28 @@
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
+ })
@@ -0,0 +1,91 @@
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
+ })