@sales-bot-llm/sdk 0.2.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.
Files changed (54) hide show
  1. package/biome.json +36 -0
  2. package/docs/superpowers/plans/2026-05-08-sales-bot-sdk-plan.md +258 -0
  3. package/docs/superpowers/plans/2026-05-11-w3-sales-tool-polish-plan.md +476 -0
  4. package/docs/superpowers/specs/2026-05-08-sales-bot-sdk-design.md +587 -0
  5. package/example/.env.example +5 -0
  6. package/example/README.md +90 -0
  7. package/example/index.html +12 -0
  8. package/example/package.json +27 -0
  9. package/example/public/vanilla.global.js +345 -0
  10. package/example/src/App.tsx +50 -0
  11. package/example/src/main.tsx +16 -0
  12. package/example/src/routes/HookDemo.tsx +174 -0
  13. package/example/src/routes/VanillaDemo.tsx +67 -0
  14. package/example/src/routes/WidgetDemo.tsx +55 -0
  15. package/example/src/styles.css +18 -0
  16. package/example/tsconfig.json +19 -0
  17. package/example/tsconfig.tsbuildinfo +1 -0
  18. package/example/vite.config.ts +4 -0
  19. package/package.json +106 -0
  20. package/pnpm-workspace.yaml +3 -0
  21. package/src/core/client.ts +245 -0
  22. package/src/core/conversation.ts +34 -0
  23. package/src/core/index.ts +6 -0
  24. package/src/core/sse-parser.ts +87 -0
  25. package/src/core/storage.ts +72 -0
  26. package/src/core/transport.ts +271 -0
  27. package/src/core/types.ts +314 -0
  28. package/src/core/visitor.ts +21 -0
  29. package/src/react/index.ts +2 -0
  30. package/src/react/use-sales-bot.tsx +182 -0
  31. package/src/vanilla/index.ts +38 -0
  32. package/src/vue/index.ts +2 -0
  33. package/src/vue/use-sales-bot.ts +152 -0
  34. package/src/widget/index.ts +3 -0
  35. package/src/widget/markdown.ts +69 -0
  36. package/src/widget/styles.ts +350 -0
  37. package/src/widget/widget.ts +442 -0
  38. package/tests/contract/wire-format.test.ts +158 -0
  39. package/tests/core/client.test.ts +292 -0
  40. package/tests/core/conversation.test.ts +41 -0
  41. package/tests/core/sse-parser.test.ts +142 -0
  42. package/tests/core/storage.test.ts +78 -0
  43. package/tests/core/transport.test.ts +204 -0
  44. package/tests/core/visitor.test.ts +42 -0
  45. package/tests/react/use-sales-bot.test.tsx +188 -0
  46. package/tests/sales-tool-discriminator.test.ts +45 -0
  47. package/tests/setup.ts +3 -0
  48. package/tests/vanilla/vanilla.test.ts +37 -0
  49. package/tests/vue/use-sales-bot.test.ts +163 -0
  50. package/tests/widget/markdown.test.ts +113 -0
  51. package/tests/widget/widget.test.ts +388 -0
  52. package/tsconfig.json +28 -0
  53. package/tsup.config.ts +38 -0
  54. package/vitest.config.ts +26 -0
@@ -0,0 +1,292 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { SalesBotClient } from '../../src/core/client'
3
+ import { MemoryStorageAdapter } from '../../src/core/storage'
4
+ import { SalesBotError } from '../../src/core/types'
5
+ import type { SalesBotEvent } from '../../src/core/types'
6
+
7
+ const EMBED_KEY = 'pk_live_testkey123'
8
+ const BASE_URL = 'http://localhost:3000'
9
+
10
+ function encodeSse(name: string, data: unknown): string {
11
+ return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`
12
+ }
13
+
14
+ function makeSseStream(...events: Array<[string, unknown]>): ReadableStream<Uint8Array> {
15
+ const raw = events.map(([name, data]) => encodeSse(name, data)).join('')
16
+ const encoder = new TextEncoder()
17
+ return new ReadableStream({
18
+ start(ctrl) {
19
+ ctrl.enqueue(encoder.encode(raw))
20
+ ctrl.close()
21
+ },
22
+ })
23
+ }
24
+
25
+ function mockFetchSSE(...events: Array<[string, unknown]>) {
26
+ const body = makeSseStream(...events)
27
+ vi.stubGlobal(
28
+ 'fetch',
29
+ vi.fn().mockResolvedValueOnce(
30
+ new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } }),
31
+ ),
32
+ )
33
+ }
34
+
35
+ function mockFetchError(status: number, code: string, message: string, retryable = false) {
36
+ vi.stubGlobal(
37
+ 'fetch',
38
+ vi.fn().mockResolvedValueOnce(
39
+ new Response(JSON.stringify({ code, message, retryable }), {
40
+ status,
41
+ headers: { 'content-type': 'application/json' },
42
+ }),
43
+ ),
44
+ )
45
+ }
46
+
47
+ afterEach(() => vi.unstubAllGlobals())
48
+
49
+ function makeClient() {
50
+ return new SalesBotClient({
51
+ embedKey: EMBED_KEY,
52
+ baseUrl: BASE_URL,
53
+ storage: new MemoryStorageAdapter(),
54
+ })
55
+ }
56
+
57
+ describe('SalesBotClient', () => {
58
+ describe('construction', () => {
59
+ it('creates a stable visitor token', () => {
60
+ const c = makeClient()
61
+ const t1 = c.getVisitorToken()
62
+ const t2 = c.getVisitorToken()
63
+ expect(t1).toBe(t2)
64
+ expect(t1).toMatch(/^[0-9a-f-]{36}$/i)
65
+ })
66
+
67
+ it('starts with null conversationId', () => {
68
+ const c = makeClient()
69
+ expect(c.getConversationId()).toBeNull()
70
+ })
71
+
72
+ it('hydrates conversationId from storage (simulates page refresh)', () => {
73
+ // Shared storage represents the browser's localStorage surviving a reload.
74
+ const storage = new MemoryStorageAdapter()
75
+
76
+ // First client picks up a conversation id via turn_started.
77
+ const first = new SalesBotClient({ embedKey: EMBED_KEY, baseUrl: BASE_URL, storage })
78
+ first.setConversationId('conv-restored')
79
+
80
+ // A second client constructed against the same storage (i.e. after refresh)
81
+ // must read the persisted conversation id.
82
+ const second = new SalesBotClient({ embedKey: EMBED_KEY, baseUrl: BASE_URL, storage })
83
+ expect(second.getConversationId()).toBe('conv-restored')
84
+ })
85
+
86
+ it('scopes persisted conversationId by embed key', () => {
87
+ const storage = new MemoryStorageAdapter()
88
+ new SalesBotClient({ embedKey: 'pk_live_a', baseUrl: BASE_URL, storage }).setConversationId('conv-A')
89
+ const b = new SalesBotClient({ embedKey: 'pk_live_b', baseUrl: BASE_URL, storage })
90
+ expect(b.getConversationId()).toBeNull()
91
+ })
92
+ })
93
+
94
+ describe('setConversationId / getConversationId', () => {
95
+ it('sets and gets conversation id', () => {
96
+ const c = makeClient()
97
+ c.setConversationId('conv-1')
98
+ expect(c.getConversationId()).toBe('conv-1')
99
+ })
100
+
101
+ it('can be cleared to null', () => {
102
+ const c = makeClient()
103
+ c.setConversationId('conv-1')
104
+ c.setConversationId(null)
105
+ expect(c.getConversationId()).toBeNull()
106
+ })
107
+
108
+ it('persists across client instances when set explicitly', () => {
109
+ const storage = new MemoryStorageAdapter()
110
+ new SalesBotClient({ embedKey: EMBED_KEY, baseUrl: BASE_URL, storage }).setConversationId('conv-x')
111
+ const fresh = new SalesBotClient({ embedKey: EMBED_KEY, baseUrl: BASE_URL, storage })
112
+ expect(fresh.getConversationId()).toBe('conv-x')
113
+ })
114
+
115
+ it('clearing with setConversationId(null) removes it from storage too', () => {
116
+ const storage = new MemoryStorageAdapter()
117
+ const c = new SalesBotClient({ embedKey: EMBED_KEY, baseUrl: BASE_URL, storage })
118
+ c.setConversationId('conv-x')
119
+ c.setConversationId(null)
120
+ const fresh = new SalesBotClient({ embedKey: EMBED_KEY, baseUrl: BASE_URL, storage })
121
+ expect(fresh.getConversationId()).toBeNull()
122
+ })
123
+ })
124
+
125
+ describe('identify', () => {
126
+ it('stores identify data for the next ask', async () => {
127
+ const c = makeClient()
128
+ c.identify({ externalId: 'user-1', email: 'a@b.com' })
129
+ mockFetchSSE(['done', { turnId: 't' }])
130
+ // Consume the stream
131
+ for await (const _ of c.ask('hello')) {/* empty */}
132
+ const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
133
+ const body = JSON.parse(init.body as string)
134
+ expect(body.identify).toEqual({ externalId: 'user-1', email: 'a@b.com' })
135
+ })
136
+ })
137
+
138
+ describe('ask', () => {
139
+ it('calls postTurn with the right body', async () => {
140
+ mockFetchSSE(['done', { turnId: 't' }])
141
+ const c = makeClient()
142
+ c.setConversationId('conv-42')
143
+ for await (const _ of c.ask('test message', { metadata: { url: 'https://x.com' } })) {/* empty */}
144
+ const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
145
+ const body = JSON.parse(init.body as string)
146
+ expect(body.message).toBe('test message')
147
+ expect(body.conversationId).toBe('conv-42')
148
+ expect(body.metadata).toEqual({ url: 'https://x.com' })
149
+ expect(body.visitorToken).toMatch(/^[0-9a-f-]{36}$/i)
150
+ })
151
+
152
+ it('yields events from the SSE stream', async () => {
153
+ const turnStarted = { turnId: 't-1', conversationId: 'c-1', endUserId: 'e-1' }
154
+ const delta = { content: 'Hello' }
155
+ const done = { turnId: 't-1' }
156
+ mockFetchSSE(
157
+ ['turn_started', turnStarted],
158
+ ['delta', delta],
159
+ ['done', done],
160
+ )
161
+ const c = makeClient()
162
+ const events: SalesBotEvent[] = []
163
+ for await (const event of c.ask('hi')) {
164
+ events.push(event)
165
+ }
166
+ expect(events).toHaveLength(3)
167
+ expect(events[0]).toEqual({ event: 'turn_started', data: turnStarted })
168
+ expect(events[1]).toEqual({ event: 'delta', data: delta })
169
+ expect(events[2]).toEqual({ event: 'done', data: done })
170
+ })
171
+
172
+ it('auto-updates conversationId from turn_started', async () => {
173
+ mockFetchSSE(
174
+ ['turn_started', { turnId: 't-1', conversationId: 'new-conv', endUserId: 'e-1' }],
175
+ ['done', { turnId: 't-1' }],
176
+ )
177
+ const c = makeClient()
178
+ for await (const _ of c.ask('hi')) {/* empty */}
179
+ expect(c.getConversationId()).toBe('new-conv')
180
+ })
181
+
182
+ it('emits events on the event bus simultaneously', async () => {
183
+ const received: SalesBotEvent[] = []
184
+ const c = makeClient()
185
+ c.on('delta', (data) => received.push({ event: 'delta', data }))
186
+ c.on('done', (data) => received.push({ event: 'done', data }))
187
+
188
+ mockFetchSSE(
189
+ ['delta', { content: 'a' }],
190
+ ['delta', { content: 'b' }],
191
+ ['done', { turnId: 't' }],
192
+ )
193
+
194
+ // Consume async iterable (which drives the bus)
195
+ for await (const _ of c.ask('hi')) {/* empty */}
196
+
197
+ expect(received).toHaveLength(3)
198
+ expect(received[0]).toEqual({ event: 'delta', data: { content: 'a' } })
199
+ expect(received[1]).toEqual({ event: 'delta', data: { content: 'b' } })
200
+ expect(received[2]).toEqual({ event: 'done', data: { turnId: 't' } })
201
+ })
202
+
203
+ it('throws SalesBotError on HTTP error', async () => {
204
+ mockFetchError(429, 'rate_limited', 'Too fast', true)
205
+ const c = makeClient()
206
+ await expect(async () => {
207
+ for await (const _ of c.ask('hi')) {/* empty */}
208
+ }).rejects.toMatchObject({ code: 'rate_limited' })
209
+ })
210
+
211
+ it('each ask uses a different idempotency key', async () => {
212
+ const c = makeClient()
213
+
214
+ function makeBody(turnId: string): ReadableStream<Uint8Array> {
215
+ const raw = encodeSse('done', { turnId })
216
+ const encoder = new TextEncoder()
217
+ return new ReadableStream({ start(ctrl) { ctrl.enqueue(encoder.encode(raw)); ctrl.close() } })
218
+ }
219
+
220
+ // Set up a mock that responds twice, tracking all calls
221
+ const mockFn = vi.fn()
222
+ .mockResolvedValueOnce(new Response(makeBody('t1'), { status: 200, headers: { 'content-type': 'text/event-stream' } }))
223
+ .mockResolvedValueOnce(new Response(makeBody('t2'), { status: 200, headers: { 'content-type': 'text/event-stream' } }))
224
+ vi.stubGlobal('fetch', mockFn)
225
+
226
+ for await (const _ of c.ask('first')) {/* empty */}
227
+ for await (const _ of c.ask('second')) {/* empty */}
228
+
229
+ const [, init1] = mockFn.mock.calls[0] as [string, RequestInit]
230
+ const [, init2] = mockFn.mock.calls[1] as [string, RequestInit]
231
+ const key1 = (init1.headers as Record<string, string>)['Idempotency-Key']
232
+ const key2 = (init2.headers as Record<string, string>)['Idempotency-Key']
233
+
234
+ expect(key1).toMatch(/^[0-9a-f-]{36}$/i)
235
+ expect(key2).toMatch(/^[0-9a-f-]{36}$/i)
236
+ expect(key1).not.toBe(key2)
237
+ })
238
+ })
239
+
240
+ describe('resume', () => {
241
+ it('calls getResumeStream with the turn id', async () => {
242
+ mockFetchSSE(['done', { turnId: 'existing-turn' }])
243
+ const c = makeClient()
244
+ for await (const _ of c.resume('existing-turn')) {/* empty */}
245
+ const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string]
246
+ expect(url).toBe(`${BASE_URL}/api/chat/turns/existing-turn/stream`)
247
+ })
248
+
249
+ it('yields events from the resumed stream', async () => {
250
+ mockFetchSSE(
251
+ ['delta', { content: 'resumed' }],
252
+ ['done', { turnId: 'rt-1' }],
253
+ )
254
+ const c = makeClient()
255
+ const events: SalesBotEvent[] = []
256
+ for await (const e of c.resume('rt-1')) events.push(e)
257
+ expect(events).toHaveLength(2)
258
+ })
259
+ })
260
+
261
+ describe('on / off', () => {
262
+ it('on() returns an unsubscribe function', () => {
263
+ const c = makeClient()
264
+ const unsub = c.on('delta', () => {})
265
+ expect(typeof unsub).toBe('function')
266
+ })
267
+
268
+ it('unsubscribe stops receiving events', async () => {
269
+ const c = makeClient()
270
+ const calls: string[] = []
271
+ const unsub = c.on('delta', (data) => calls.push((data as { content: string }).content))
272
+ unsub()
273
+
274
+ mockFetchSSE(['delta', { content: 'x' }], ['done', { turnId: 't' }])
275
+ for await (const _ of c.ask('hi')) {/* empty */}
276
+ expect(calls).toHaveLength(0)
277
+ })
278
+
279
+ it('multiple handlers for same event all fire', async () => {
280
+ const c = makeClient()
281
+ const a: string[] = []
282
+ const b: string[] = []
283
+ c.on('delta', (d) => a.push((d as { content: string }).content))
284
+ c.on('delta', (d) => b.push((d as { content: string }).content))
285
+
286
+ mockFetchSSE(['delta', { content: 'hi' }], ['done', { turnId: 't' }])
287
+ for await (const _ of c.ask('hello')) {/* empty */}
288
+ expect(a).toEqual(['hi'])
289
+ expect(b).toEqual(['hi'])
290
+ })
291
+ })
292
+ })
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { MemoryStorageAdapter } from '../../src/core/storage'
3
+ import {
4
+ loadConversationId,
5
+ saveConversationId,
6
+ CONVERSATION_ID_KEY_PREFIX,
7
+ } from '../../src/core/conversation'
8
+
9
+ describe('conversation id persistence', () => {
10
+ it('returns null when no conversation is stored', () => {
11
+ const storage = new MemoryStorageAdapter()
12
+ expect(loadConversationId('pk_live_x', storage)).toBeNull()
13
+ })
14
+
15
+ it('round-trips a conversation id through save/load', () => {
16
+ const storage = new MemoryStorageAdapter()
17
+ saveConversationId('pk_live_x', 'conv-123', storage)
18
+ expect(loadConversationId('pk_live_x', storage)).toBe('conv-123')
19
+ })
20
+
21
+ it('saveConversationId(null) clears the stored id', () => {
22
+ const storage = new MemoryStorageAdapter()
23
+ saveConversationId('pk_live_x', 'conv-123', storage)
24
+ saveConversationId('pk_live_x', null, storage)
25
+ expect(loadConversationId('pk_live_x', storage)).toBeNull()
26
+ })
27
+
28
+ it('scopes by embed key so multiple bots on the same browser do not collide', () => {
29
+ const storage = new MemoryStorageAdapter()
30
+ saveConversationId('pk_live_a', 'conv-A', storage)
31
+ saveConversationId('pk_live_b', 'conv-B', storage)
32
+ expect(loadConversationId('pk_live_a', storage)).toBe('conv-A')
33
+ expect(loadConversationId('pk_live_b', storage)).toBe('conv-B')
34
+ })
35
+
36
+ it('uses the documented key prefix', () => {
37
+ const storage = new MemoryStorageAdapter()
38
+ saveConversationId('pk_live_x', 'conv-1', storage)
39
+ expect(storage.getItem(`${CONVERSATION_ID_KEY_PREFIX}pk_live_x`)).toBe('conv-1')
40
+ })
41
+ })
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseSseStream } from '../../src/core/sse-parser'
3
+ import { SalesBotError } from '../../src/core/types'
4
+ import type { SalesBotEvent } from '../../src/core/types'
5
+
6
+ /** Helper: create a ReadableStream<Uint8Array> from a raw SSE string */
7
+ function makeStream(raw: string): ReadableStream<Uint8Array> {
8
+ const encoder = new TextEncoder()
9
+ return new ReadableStream({
10
+ start(controller) {
11
+ controller.enqueue(encoder.encode(raw))
12
+ controller.close()
13
+ },
14
+ })
15
+ }
16
+
17
+ /** Helper: create a stream split into multiple chunks */
18
+ function makeChunkedStream(chunks: string[]): ReadableStream<Uint8Array> {
19
+ const encoder = new TextEncoder()
20
+ return new ReadableStream({
21
+ start(controller) {
22
+ for (const chunk of chunks) {
23
+ controller.enqueue(encoder.encode(chunk))
24
+ }
25
+ controller.close()
26
+ },
27
+ })
28
+ }
29
+
30
+ async function collectEvents(stream: ReadableStream<Uint8Array>): Promise<SalesBotEvent[]> {
31
+ const events: SalesBotEvent[] = []
32
+ for await (const event of parseSseStream(stream)) {
33
+ events.push(event)
34
+ }
35
+ return events
36
+ }
37
+
38
+ describe('parseSseStream', () => {
39
+ it('parses a single turn_started frame', async () => {
40
+ const data = { turnId: 't-1', conversationId: 'c-1', endUserId: 'e-1' }
41
+ const raw = `event: turn_started\ndata: ${JSON.stringify(data)}\n\n`
42
+ const events = await collectEvents(makeStream(raw))
43
+ expect(events).toHaveLength(1)
44
+ expect(events[0]).toEqual({ event: 'turn_started', data })
45
+ })
46
+
47
+ it('parses a delta frame', async () => {
48
+ const raw = `event: delta\ndata: {"content":"Hello"}\n\n`
49
+ const events = await collectEvents(makeStream(raw))
50
+ expect(events).toHaveLength(1)
51
+ expect(events[0]).toEqual({ event: 'delta', data: { content: 'Hello' } })
52
+ })
53
+
54
+ it('parses multiple frames in one chunk', async () => {
55
+ const raw =
56
+ `event: turn_started\ndata: {"turnId":"t","conversationId":"c","endUserId":"e"}\n\n` +
57
+ `event: delta\ndata: {"content":"a"}\n\n` +
58
+ `event: done\ndata: {"turnId":"t"}\n\n`
59
+ const events = await collectEvents(makeStream(raw))
60
+ expect(events).toHaveLength(3)
61
+ expect(events[0]!.event).toBe('turn_started')
62
+ expect(events[1]!.event).toBe('delta')
63
+ expect(events[2]!.event).toBe('done')
64
+ })
65
+
66
+ it('handles frames split across chunk boundaries', async () => {
67
+ const frame = `event: delta\ndata: {"content":"hi"}\n\n`
68
+ // Split the frame at an arbitrary byte boundary
69
+ const mid = Math.floor(frame.length / 2)
70
+ const chunks = [frame.slice(0, mid), frame.slice(mid)]
71
+ const events = await collectEvents(makeChunkedStream(chunks))
72
+ expect(events).toHaveLength(1)
73
+ expect(events[0]).toEqual({ event: 'delta', data: { content: 'hi' } })
74
+ })
75
+
76
+ it('handles two frames split across three chunks', async () => {
77
+ const a = `event: delta\ndata: {"content":"a"}\n\n`
78
+ const b = `event: delta\ndata: {"content":"b"}\n\n`
79
+ const combined = a + b
80
+ // split so the boundary falls in the middle of the second frame
81
+ const chunks = [combined.slice(0, a.length + 5), combined.slice(a.length + 5)]
82
+ const events = await collectEvents(makeChunkedStream(chunks))
83
+ expect(events).toHaveLength(2)
84
+ expect((events[0]!.data as { content: string }).content).toBe('a')
85
+ expect((events[1]!.data as { content: string }).content).toBe('b')
86
+ })
87
+
88
+ it('parses an error event', async () => {
89
+ const raw = `event: error\ndata: {"code":"rate_limited","message":"slow down","retryable":true}\n\n`
90
+ const events = await collectEvents(makeStream(raw))
91
+ expect(events).toHaveLength(1)
92
+ expect(events[0]).toEqual({
93
+ event: 'error',
94
+ data: { code: 'rate_limited', message: 'slow down', retryable: true },
95
+ })
96
+ })
97
+
98
+ it('parses all 8 event types', async () => {
99
+ const frames = [
100
+ `event: turn_started\ndata: {"turnId":"t","conversationId":"c","endUserId":"e"}\n\n`,
101
+ `event: delta\ndata: {"content":"x"}\n\n`,
102
+ `event: tool_call_started\ndata: {"id":"i","name":"n","args":{}}\n\n`,
103
+ `event: tool_call_finished\ndata: {"id":"i","ok":true,"durationMs":1}\n\n`,
104
+ `event: message_complete\ndata: {"messageId":"m","content":"c","modelId":"x","promptTokens":1,"completionTokens":1}\n\n`,
105
+ `event: usage\ndata: {"kind":"chat_completion","quantity":1,"costBasisCents":0}\n\n`,
106
+ `event: done\ndata: {"turnId":"t"}\n\n`,
107
+ `event: error\ndata: {"code":"internal","message":"e","retryable":false}\n\n`,
108
+ ]
109
+ const events = await collectEvents(makeStream(frames.join('')))
110
+ expect(events).toHaveLength(8)
111
+ const eventNames = events.map((e) => e.event)
112
+ expect(eventNames).toEqual([
113
+ 'turn_started', 'delta', 'tool_call_started', 'tool_call_finished',
114
+ 'message_complete', 'usage', 'done', 'error',
115
+ ])
116
+ })
117
+
118
+ it('returns no events for an empty stream', async () => {
119
+ const events = await collectEvents(makeStream(''))
120
+ expect(events).toHaveLength(0)
121
+ })
122
+
123
+ it('skips frames with no event line', async () => {
124
+ // A frame with only data (comment-only or ping): should be skipped
125
+ const raw = `data: {"content":"orphan"}\n\n`
126
+ const events = await collectEvents(makeStream(raw))
127
+ expect(events).toHaveLength(0)
128
+ })
129
+
130
+ it('throws SalesBotError with code parse_error on malformed JSON', async () => {
131
+ const raw = `event: delta\ndata: {not valid json}\n\n`
132
+ await expect(collectEvents(makeStream(raw))).rejects.toBeInstanceOf(SalesBotError)
133
+ await expect(collectEvents(makeStream(raw))).rejects.toMatchObject({ code: 'parse_error' })
134
+ })
135
+
136
+ it('handles unicode content correctly', async () => {
137
+ const content = 'こんにちは 🎉'
138
+ const raw = `event: delta\ndata: ${JSON.stringify({ content })}\n\n`
139
+ const events = await collectEvents(makeStream(raw))
140
+ expect((events[0]!.data as { content: string }).content).toBe(content)
141
+ })
142
+ })
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { LocalStorageAdapter, MemoryStorageAdapter } from '../../src/core/storage'
3
+
4
+ describe('MemoryStorageAdapter', () => {
5
+ it('returns null for missing keys', () => {
6
+ const s = new MemoryStorageAdapter()
7
+ expect(s.getItem('missing')).toBeNull()
8
+ })
9
+
10
+ it('stores and retrieves values', () => {
11
+ const s = new MemoryStorageAdapter()
12
+ s.setItem('key', 'value')
13
+ expect(s.getItem('key')).toBe('value')
14
+ })
15
+
16
+ it('overwrites existing values', () => {
17
+ const s = new MemoryStorageAdapter()
18
+ s.setItem('key', 'a')
19
+ s.setItem('key', 'b')
20
+ expect(s.getItem('key')).toBe('b')
21
+ })
22
+
23
+ it('removes items', () => {
24
+ const s = new MemoryStorageAdapter()
25
+ s.setItem('key', 'value')
26
+ s.removeItem('key')
27
+ expect(s.getItem('key')).toBeNull()
28
+ })
29
+
30
+ it('does not throw on removeItem for non-existent key', () => {
31
+ const s = new MemoryStorageAdapter()
32
+ expect(() => s.removeItem('ghost')).not.toThrow()
33
+ })
34
+
35
+ it('isolates data between instances', () => {
36
+ const a = new MemoryStorageAdapter()
37
+ const b = new MemoryStorageAdapter()
38
+ a.setItem('key', 'from-a')
39
+ expect(b.getItem('key')).toBeNull()
40
+ })
41
+ })
42
+
43
+ describe('LocalStorageAdapter', () => {
44
+ beforeEach(() => {
45
+ localStorage.clear()
46
+ })
47
+
48
+ it('returns null for missing keys', () => {
49
+ const s = new LocalStorageAdapter()
50
+ expect(s.getItem('missing')).toBeNull()
51
+ })
52
+
53
+ it('stores and retrieves values', () => {
54
+ const s = new LocalStorageAdapter()
55
+ s.setItem('key', 'value')
56
+ expect(s.getItem('key')).toBe('value')
57
+ })
58
+
59
+ it('overwrites existing values', () => {
60
+ const s = new LocalStorageAdapter()
61
+ s.setItem('key', 'a')
62
+ s.setItem('key', 'b')
63
+ expect(s.getItem('key')).toBe('b')
64
+ })
65
+
66
+ it('removes items', () => {
67
+ const s = new LocalStorageAdapter()
68
+ s.setItem('key', 'value')
69
+ s.removeItem('key')
70
+ expect(s.getItem('key')).toBeNull()
71
+ })
72
+
73
+ it('shares data with window.localStorage', () => {
74
+ const s = new LocalStorageAdapter()
75
+ s.setItem('shared', 'yes')
76
+ expect(localStorage.getItem('shared')).toBe('yes')
77
+ })
78
+ })