@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.
- package/biome.json +36 -0
- package/docs/superpowers/plans/2026-05-08-sales-bot-sdk-plan.md +258 -0
- package/docs/superpowers/plans/2026-05-11-w3-sales-tool-polish-plan.md +476 -0
- package/docs/superpowers/specs/2026-05-08-sales-bot-sdk-design.md +587 -0
- package/example/.env.example +5 -0
- package/example/README.md +90 -0
- package/example/index.html +12 -0
- package/example/package.json +27 -0
- package/example/public/vanilla.global.js +345 -0
- package/example/src/App.tsx +50 -0
- package/example/src/main.tsx +16 -0
- package/example/src/routes/HookDemo.tsx +174 -0
- package/example/src/routes/VanillaDemo.tsx +67 -0
- package/example/src/routes/WidgetDemo.tsx +55 -0
- package/example/src/styles.css +18 -0
- package/example/tsconfig.json +19 -0
- package/example/tsconfig.tsbuildinfo +1 -0
- package/example/vite.config.ts +4 -0
- package/package.json +106 -0
- package/pnpm-workspace.yaml +3 -0
- package/src/core/client.ts +245 -0
- package/src/core/conversation.ts +34 -0
- package/src/core/index.ts +6 -0
- package/src/core/sse-parser.ts +87 -0
- package/src/core/storage.ts +72 -0
- package/src/core/transport.ts +271 -0
- package/src/core/types.ts +314 -0
- package/src/core/visitor.ts +21 -0
- package/src/react/index.ts +2 -0
- package/src/react/use-sales-bot.tsx +182 -0
- package/src/vanilla/index.ts +38 -0
- package/src/vue/index.ts +2 -0
- package/src/vue/use-sales-bot.ts +152 -0
- package/src/widget/index.ts +3 -0
- package/src/widget/markdown.ts +69 -0
- package/src/widget/styles.ts +350 -0
- package/src/widget/widget.ts +442 -0
- package/tests/contract/wire-format.test.ts +158 -0
- package/tests/core/client.test.ts +292 -0
- package/tests/core/conversation.test.ts +41 -0
- package/tests/core/sse-parser.test.ts +142 -0
- package/tests/core/storage.test.ts +78 -0
- package/tests/core/transport.test.ts +204 -0
- package/tests/core/visitor.test.ts +42 -0
- package/tests/react/use-sales-bot.test.tsx +188 -0
- package/tests/sales-tool-discriminator.test.ts +45 -0
- package/tests/setup.ts +3 -0
- package/tests/vanilla/vanilla.test.ts +37 -0
- package/tests/vue/use-sales-bot.test.ts +163 -0
- package/tests/widget/markdown.test.ts +113 -0
- package/tests/widget/widget.test.ts +388 -0
- package/tsconfig.json +28 -0
- package/tsup.config.ts +38 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { postTurn, getResumeStream } from '../../src/core/transport'
|
|
3
|
+
import { SalesBotError } from '../../src/core/types'
|
|
4
|
+
|
|
5
|
+
const BASE_URL = 'http://localhost:3000'
|
|
6
|
+
const EMBED_KEY = 'pk_live_abc123'
|
|
7
|
+
|
|
8
|
+
function mockFetch(status: number, body: unknown, headers: Record<string, string> = {}) {
|
|
9
|
+
const responseHeaders = new Headers({
|
|
10
|
+
'content-type': 'text/event-stream',
|
|
11
|
+
...headers,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const bodyStr = typeof body === 'string' ? body : JSON.stringify(body)
|
|
15
|
+
const stream = new ReadableStream({
|
|
16
|
+
start(ctrl) {
|
|
17
|
+
ctrl.enqueue(new TextEncoder().encode(bodyStr))
|
|
18
|
+
ctrl.close()
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
vi.stubGlobal(
|
|
23
|
+
'fetch',
|
|
24
|
+
vi.fn().mockResolvedValueOnce(
|
|
25
|
+
new Response(stream, { status, headers: responseHeaders }),
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mockFetchWithJsonBody(status: number, body: unknown) {
|
|
31
|
+
vi.stubGlobal(
|
|
32
|
+
'fetch',
|
|
33
|
+
vi.fn().mockResolvedValueOnce(
|
|
34
|
+
new Response(JSON.stringify(body), {
|
|
35
|
+
status,
|
|
36
|
+
headers: { 'content-type': 'application/json' },
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mockFetchThrows(err: Error) {
|
|
43
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(err))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.unstubAllGlobals()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('postTurn', () => {
|
|
51
|
+
it('calls POST /api/chat/turns', async () => {
|
|
52
|
+
mockFetch(200, '')
|
|
53
|
+
await postTurn(
|
|
54
|
+
{ visitorToken: 'vt', message: 'hello' },
|
|
55
|
+
{ embedKey: EMBED_KEY, baseUrl: BASE_URL },
|
|
56
|
+
)
|
|
57
|
+
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
|
|
58
|
+
expect(url).toBe(`${BASE_URL}/api/chat/turns`)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('uses POST method', async () => {
|
|
62
|
+
mockFetch(200, '')
|
|
63
|
+
await postTurn({ visitorToken: 'vt', message: 'hello' }, { embedKey: EMBED_KEY, baseUrl: BASE_URL })
|
|
64
|
+
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
|
|
65
|
+
expect(init.method).toBe('POST')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('sets Authorization: Bearer <embedKey>', async () => {
|
|
69
|
+
mockFetch(200, '')
|
|
70
|
+
await postTurn({ visitorToken: 'vt', message: 'hello' }, { embedKey: EMBED_KEY, baseUrl: BASE_URL })
|
|
71
|
+
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
|
|
72
|
+
const headers = init.headers as Record<string, string>
|
|
73
|
+
expect(headers['Authorization']).toBe(`Bearer ${EMBED_KEY}`)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('sets Accept: text/event-stream', async () => {
|
|
77
|
+
mockFetch(200, '')
|
|
78
|
+
await postTurn({ visitorToken: 'vt', message: 'hello' }, { embedKey: EMBED_KEY, baseUrl: BASE_URL })
|
|
79
|
+
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
|
|
80
|
+
const headers = init.headers as Record<string, string>
|
|
81
|
+
expect(headers['Accept']).toBe('text/event-stream')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('sets Content-Type: application/json', async () => {
|
|
85
|
+
mockFetch(200, '')
|
|
86
|
+
await postTurn({ visitorToken: 'vt', message: 'hello' }, { embedKey: EMBED_KEY, baseUrl: BASE_URL })
|
|
87
|
+
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
|
|
88
|
+
const headers = init.headers as Record<string, string>
|
|
89
|
+
expect(headers['Content-Type']).toBe('application/json')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('sets an Idempotency-Key header', async () => {
|
|
93
|
+
mockFetch(200, '')
|
|
94
|
+
await postTurn({ visitorToken: 'vt', message: 'hello' }, { embedKey: EMBED_KEY, baseUrl: BASE_URL })
|
|
95
|
+
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
|
|
96
|
+
const headers = init.headers as Record<string, string>
|
|
97
|
+
expect(headers['Idempotency-Key']).toMatch(
|
|
98
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('sends the correct JSON body', async () => {
|
|
103
|
+
mockFetch(200, '')
|
|
104
|
+
await postTurn(
|
|
105
|
+
{ visitorToken: 'vt-1', message: 'hi', conversationId: 'c-1' },
|
|
106
|
+
{ embedKey: EMBED_KEY, baseUrl: BASE_URL },
|
|
107
|
+
)
|
|
108
|
+
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
|
|
109
|
+
const body = JSON.parse(init.body as string)
|
|
110
|
+
expect(body).toMatchObject({ visitorToken: 'vt-1', message: 'hi', conversationId: 'c-1' })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('returns a ReadableStream on 200', async () => {
|
|
114
|
+
mockFetch(200, '')
|
|
115
|
+
const stream = await postTurn(
|
|
116
|
+
{ visitorToken: 'vt', message: 'hello' },
|
|
117
|
+
{ embedKey: EMBED_KEY, baseUrl: BASE_URL },
|
|
118
|
+
)
|
|
119
|
+
expect(stream).toBeInstanceOf(ReadableStream)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('merges customHeaders', async () => {
|
|
123
|
+
mockFetch(200, '')
|
|
124
|
+
await postTurn(
|
|
125
|
+
{ visitorToken: 'vt', message: 'hello' },
|
|
126
|
+
{ embedKey: EMBED_KEY, baseUrl: BASE_URL, customHeaders: { 'X-Custom': 'yes' } },
|
|
127
|
+
)
|
|
128
|
+
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
|
|
129
|
+
const headers = init.headers as Record<string, string>
|
|
130
|
+
expect(headers['X-Custom']).toBe('yes')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('maps 401 with body code invalid_embed_key → SalesBotError(invalid_embed_key)', async () => {
|
|
134
|
+
mockFetchWithJsonBody(401, { code: 'invalid_embed_key', message: 'bad key', retryable: false })
|
|
135
|
+
await expect(
|
|
136
|
+
postTurn({ visitorToken: 'vt', message: 'hello' }, { embedKey: EMBED_KEY, baseUrl: BASE_URL }),
|
|
137
|
+
).rejects.toMatchObject({ code: 'invalid_embed_key' })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('maps 402 → out_of_credits', async () => {
|
|
141
|
+
mockFetchWithJsonBody(402, { code: 'out_of_credits', message: 'no credits', retryable: false })
|
|
142
|
+
await expect(
|
|
143
|
+
postTurn({ visitorToken: 'vt', message: 'hello' }, { embedKey: EMBED_KEY, baseUrl: BASE_URL }),
|
|
144
|
+
).rejects.toMatchObject({ code: 'out_of_credits' })
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('maps 403 with body code origin_not_allowed → origin_not_allowed', async () => {
|
|
148
|
+
mockFetchWithJsonBody(403, { code: 'origin_not_allowed', message: 'no', retryable: false })
|
|
149
|
+
await expect(
|
|
150
|
+
postTurn({ visitorToken: 'vt', message: 'hello' }, { embedKey: EMBED_KEY, baseUrl: BASE_URL }),
|
|
151
|
+
).rejects.toMatchObject({ code: 'origin_not_allowed' })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('maps 429 → rate_limited', async () => {
|
|
155
|
+
mockFetchWithJsonBody(429, { code: 'rate_limited', message: 'slow down', retryable: true })
|
|
156
|
+
await expect(
|
|
157
|
+
postTurn({ visitorToken: 'vt', message: 'hello' }, { embedKey: EMBED_KEY, baseUrl: BASE_URL }),
|
|
158
|
+
).rejects.toMatchObject({ code: 'rate_limited', retryable: true })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('maps 500 → internal', async () => {
|
|
162
|
+
mockFetchWithJsonBody(500, { code: 'internal', message: 'oops', retryable: false })
|
|
163
|
+
await expect(
|
|
164
|
+
postTurn({ visitorToken: 'vt', message: 'hello' }, { embedKey: EMBED_KEY, baseUrl: BASE_URL }),
|
|
165
|
+
).rejects.toMatchObject({ code: 'internal' })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('maps fetch throw → SalesBotError(network_error)', async () => {
|
|
169
|
+
mockFetchThrows(new TypeError('Failed to fetch'))
|
|
170
|
+
await expect(
|
|
171
|
+
postTurn({ visitorToken: 'vt', message: 'hello' }, { embedKey: EMBED_KEY, baseUrl: BASE_URL }),
|
|
172
|
+
).rejects.toMatchObject({ code: 'network_error' })
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('getResumeStream', () => {
|
|
177
|
+
it('calls GET /api/chat/turns/:turnId/stream', async () => {
|
|
178
|
+
mockFetch(200, '')
|
|
179
|
+
await getResumeStream('turn-42', { embedKey: EMBED_KEY, baseUrl: BASE_URL })
|
|
180
|
+
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
|
|
181
|
+
expect(url).toBe(`${BASE_URL}/api/chat/turns/turn-42/stream`)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('uses GET method', async () => {
|
|
185
|
+
mockFetch(200, '')
|
|
186
|
+
await getResumeStream('turn-42', { embedKey: EMBED_KEY, baseUrl: BASE_URL })
|
|
187
|
+
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
|
|
188
|
+
expect(init.method).toBe('GET')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('sets Authorization header', async () => {
|
|
192
|
+
mockFetch(200, '')
|
|
193
|
+
await getResumeStream('turn-42', { embedKey: EMBED_KEY, baseUrl: BASE_URL })
|
|
194
|
+
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
|
|
195
|
+
const headers = init.headers as Record<string, string>
|
|
196
|
+
expect(headers['Authorization']).toBe(`Bearer ${EMBED_KEY}`)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('returns a ReadableStream on 200', async () => {
|
|
200
|
+
mockFetch(200, '')
|
|
201
|
+
const stream = await getResumeStream('turn-42', { embedKey: EMBED_KEY, baseUrl: BASE_URL })
|
|
202
|
+
expect(stream).toBeInstanceOf(ReadableStream)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { MemoryStorageAdapter } from '../../src/core/storage'
|
|
3
|
+
import { getOrCreateVisitorToken, VISITOR_TOKEN_KEY_PREFIX } from '../../src/core/visitor'
|
|
4
|
+
|
|
5
|
+
describe('getOrCreateVisitorToken', () => {
|
|
6
|
+
it('generates a UUID on first call', () => {
|
|
7
|
+
const storage = new MemoryStorageAdapter()
|
|
8
|
+
const token = getOrCreateVisitorToken('pk_live_abc', storage)
|
|
9
|
+
expect(token).toMatch(
|
|
10
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
11
|
+
)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns the same token on subsequent calls', () => {
|
|
15
|
+
const storage = new MemoryStorageAdapter()
|
|
16
|
+
const t1 = getOrCreateVisitorToken('pk_live_abc', storage)
|
|
17
|
+
const t2 = getOrCreateVisitorToken('pk_live_abc', storage)
|
|
18
|
+
expect(t1).toBe(t2)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('namespaces the key by embedKey', () => {
|
|
22
|
+
const storage = new MemoryStorageAdapter()
|
|
23
|
+
const t1 = getOrCreateVisitorToken('pk_live_aaa', storage)
|
|
24
|
+
const t2 = getOrCreateVisitorToken('pk_live_bbb', storage)
|
|
25
|
+
expect(t1).not.toBe(t2)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('stores the token under the correct key', () => {
|
|
29
|
+
const storage = new MemoryStorageAdapter()
|
|
30
|
+
const token = getOrCreateVisitorToken('pk_live_xyz', storage)
|
|
31
|
+
const key = `${VISITOR_TOKEN_KEY_PREFIX}pk_live_xyz`
|
|
32
|
+
expect(storage.getItem(key)).toBe(token)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('reads from storage if already set externally', () => {
|
|
36
|
+
const storage = new MemoryStorageAdapter()
|
|
37
|
+
const existing = '550e8400-e29b-41d4-a716-446655440000'
|
|
38
|
+
storage.setItem(`${VISITOR_TOKEN_KEY_PREFIX}pk_live_abc`, existing)
|
|
39
|
+
const token = getOrCreateVisitorToken('pk_live_abc', storage)
|
|
40
|
+
expect(token).toBe(existing)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
import { useSalesBot } from '../../src/react/use-sales-bot'
|
|
4
|
+
import { SalesBotError } from '../../src/core/types'
|
|
5
|
+
|
|
6
|
+
const EMBED_KEY = 'pk_live_reacttest'
|
|
7
|
+
|
|
8
|
+
function encodeSse(name: string, data: unknown): string {
|
|
9
|
+
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeSseStream(...events: Array<[string, unknown]>): ReadableStream<Uint8Array> {
|
|
13
|
+
const raw = events.map(([n, d]) => encodeSse(n, d)).join('')
|
|
14
|
+
const encoder = new TextEncoder()
|
|
15
|
+
return new ReadableStream({
|
|
16
|
+
start(ctrl) {
|
|
17
|
+
ctrl.enqueue(encoder.encode(raw))
|
|
18
|
+
ctrl.close()
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mockFetchSSE(...events: Array<[string, unknown]>) {
|
|
24
|
+
vi.stubGlobal(
|
|
25
|
+
'fetch',
|
|
26
|
+
vi.fn().mockResolvedValueOnce(
|
|
27
|
+
new Response(makeSseStream(...events), {
|
|
28
|
+
status: 200,
|
|
29
|
+
headers: { 'content-type': 'text/event-stream' },
|
|
30
|
+
}),
|
|
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
|
+
describe('useSalesBot', () => {
|
|
50
|
+
it('returns a stable shape', () => {
|
|
51
|
+
const { result } = renderHook(() =>
|
|
52
|
+
useSalesBot({ embedKey: EMBED_KEY, baseUrl: 'http://localhost:3000' }),
|
|
53
|
+
)
|
|
54
|
+
expect(typeof result.current.ask).toBe('function')
|
|
55
|
+
expect(Array.isArray(result.current.messages)).toBe(true)
|
|
56
|
+
expect(typeof result.current.isStreaming).toBe('boolean')
|
|
57
|
+
expect(result.current.error).toBeNull()
|
|
58
|
+
expect(typeof result.current.reset).toBe('function')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('starts with empty messages and not streaming', () => {
|
|
62
|
+
const { result } = renderHook(() =>
|
|
63
|
+
useSalesBot({ embedKey: EMBED_KEY, baseUrl: 'http://localhost:3000' }),
|
|
64
|
+
)
|
|
65
|
+
expect(result.current.messages).toHaveLength(0)
|
|
66
|
+
expect(result.current.isStreaming).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('ask appends user message immediately', async () => {
|
|
70
|
+
mockFetchSSE(['done', { turnId: 't' }])
|
|
71
|
+
const { result } = renderHook(() =>
|
|
72
|
+
useSalesBot({ embedKey: EMBED_KEY, baseUrl: 'http://localhost:3000' }),
|
|
73
|
+
)
|
|
74
|
+
await act(async () => {
|
|
75
|
+
await result.current.ask('Hello!')
|
|
76
|
+
})
|
|
77
|
+
const userMsg = result.current.messages.find((m) => m.role === 'user')
|
|
78
|
+
expect(userMsg?.content).toBe('Hello!')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('isStreaming becomes true during ask, false after done', async () => {
|
|
82
|
+
// We need to capture streaming state mid-stream.
|
|
83
|
+
// Use a stream that resolves after we can check.
|
|
84
|
+
const encoder = new TextEncoder()
|
|
85
|
+
let resolveStream!: () => void
|
|
86
|
+
const streamPromise = new Promise<void>((res) => { resolveStream = res })
|
|
87
|
+
|
|
88
|
+
vi.stubGlobal(
|
|
89
|
+
'fetch',
|
|
90
|
+
vi.fn().mockResolvedValueOnce(
|
|
91
|
+
new Response(
|
|
92
|
+
new ReadableStream({
|
|
93
|
+
async start(ctrl) {
|
|
94
|
+
ctrl.enqueue(encoder.encode(encodeSse('turn_started', { turnId: 't', conversationId: 'c', endUserId: 'e' })))
|
|
95
|
+
await streamPromise
|
|
96
|
+
ctrl.enqueue(encoder.encode(encodeSse('done', { turnId: 't' })))
|
|
97
|
+
ctrl.close()
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
{ status: 200, headers: { 'content-type': 'text/event-stream' } },
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const { result } = renderHook(() =>
|
|
106
|
+
useSalesBot({ embedKey: EMBED_KEY, baseUrl: 'http://localhost:3000' }),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
let askPromise: Promise<void>
|
|
110
|
+
act(() => {
|
|
111
|
+
askPromise = result.current.ask('hello')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// Give the stream a tick to start
|
|
115
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
116
|
+
expect(result.current.isStreaming).toBe(true)
|
|
117
|
+
|
|
118
|
+
// Finish the stream
|
|
119
|
+
resolveStream()
|
|
120
|
+
await act(async () => { await askPromise })
|
|
121
|
+
expect(result.current.isStreaming).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('accumulates delta content into assistant message', async () => {
|
|
125
|
+
mockFetchSSE(
|
|
126
|
+
['turn_started', { turnId: 't', conversationId: 'c', endUserId: 'e' }],
|
|
127
|
+
['delta', { content: 'Hello ' }],
|
|
128
|
+
['delta', { content: 'world' }],
|
|
129
|
+
['message_complete', { messageId: 'm', content: 'Hello world', modelId: 'x', promptTokens: 5, completionTokens: 5 }],
|
|
130
|
+
['done', { turnId: 't' }],
|
|
131
|
+
)
|
|
132
|
+
const { result } = renderHook(() =>
|
|
133
|
+
useSalesBot({ embedKey: EMBED_KEY, baseUrl: 'http://localhost:3000' }),
|
|
134
|
+
)
|
|
135
|
+
await act(async () => { await result.current.ask('hi') })
|
|
136
|
+
|
|
137
|
+
const assistantMsg = result.current.messages.find((m) => m.role === 'assistant')
|
|
138
|
+
expect(assistantMsg?.content).toBe('Hello world')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('sets conversationId from turn_started', async () => {
|
|
142
|
+
mockFetchSSE(
|
|
143
|
+
['turn_started', { turnId: 't', conversationId: 'conv-abc', endUserId: 'e' }],
|
|
144
|
+
['done', { turnId: 't' }],
|
|
145
|
+
)
|
|
146
|
+
const { result } = renderHook(() =>
|
|
147
|
+
useSalesBot({ embedKey: EMBED_KEY, baseUrl: 'http://localhost:3000' }),
|
|
148
|
+
)
|
|
149
|
+
await act(async () => { await result.current.ask('hi') })
|
|
150
|
+
expect(result.current.conversationId).toBe('conv-abc')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('sets error on SalesBotError', async () => {
|
|
154
|
+
mockFetchError(429, 'rate_limited', 'Too fast', true)
|
|
155
|
+
const { result } = renderHook(() =>
|
|
156
|
+
useSalesBot({ embedKey: EMBED_KEY, baseUrl: 'http://localhost:3000' }),
|
|
157
|
+
)
|
|
158
|
+
await act(async () => {
|
|
159
|
+
await result.current.ask('hi')
|
|
160
|
+
})
|
|
161
|
+
expect(result.current.error).toBeInstanceOf(SalesBotError)
|
|
162
|
+
expect(result.current.error?.code).toBe('rate_limited')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('reset clears messages and error', async () => {
|
|
166
|
+
mockFetchError(500, 'internal', 'oops')
|
|
167
|
+
const { result } = renderHook(() =>
|
|
168
|
+
useSalesBot({ embedKey: EMBED_KEY, baseUrl: 'http://localhost:3000' }),
|
|
169
|
+
)
|
|
170
|
+
await act(async () => { await result.current.ask('hi') })
|
|
171
|
+
expect(result.current.messages.length).toBeGreaterThan(0)
|
|
172
|
+
|
|
173
|
+
act(() => { result.current.reset() })
|
|
174
|
+
expect(result.current.messages).toHaveLength(0)
|
|
175
|
+
expect(result.current.error).toBeNull()
|
|
176
|
+
expect(result.current.conversationId).toBeNull()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('client is not re-created on re-render', () => {
|
|
180
|
+
const { result, rerender } = renderHook(() =>
|
|
181
|
+
useSalesBot({ embedKey: EMBED_KEY, baseUrl: 'http://localhost:3000' }),
|
|
182
|
+
)
|
|
183
|
+
const token1 = result.current.visitorToken
|
|
184
|
+
rerender()
|
|
185
|
+
const token2 = result.current.visitorToken
|
|
186
|
+
expect(token1).toBe(token2)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
SALES_WORKFLOW_TOOL_NAMES,
|
|
4
|
+
isSalesWorkflowTool,
|
|
5
|
+
type SalesWorkflowToolName,
|
|
6
|
+
} from '../src/core/types';
|
|
7
|
+
|
|
8
|
+
describe('SALES_WORKFLOW_TOOL_NAMES', () => {
|
|
9
|
+
it('is a readonly tuple containing all five sales-workflow tool names', () => {
|
|
10
|
+
expect(SALES_WORKFLOW_TOOL_NAMES).toEqual([
|
|
11
|
+
'project_brief__set_field',
|
|
12
|
+
'project_brief__get_current',
|
|
13
|
+
'quotes__generate',
|
|
14
|
+
'quotes__send_as_pdf',
|
|
15
|
+
'quotes__send_as_proposal',
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('isSalesWorkflowTool', () => {
|
|
21
|
+
it('returns true for each sales-workflow tool name', () => {
|
|
22
|
+
expect(isSalesWorkflowTool('project_brief__set_field')).toBe(true);
|
|
23
|
+
expect(isSalesWorkflowTool('project_brief__get_current')).toBe(true);
|
|
24
|
+
expect(isSalesWorkflowTool('quotes__generate')).toBe(true);
|
|
25
|
+
expect(isSalesWorkflowTool('quotes__send_as_pdf')).toBe(true);
|
|
26
|
+
expect(isSalesWorkflowTool('quotes__send_as_proposal')).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns false for unrelated tool names', () => {
|
|
30
|
+
expect(isSalesWorkflowTool('chaindoc_media_upload')).toBe(false);
|
|
31
|
+
expect(isSalesWorkflowTool('llms_txt__search')).toBe(false);
|
|
32
|
+
expect(isSalesWorkflowTool('')).toBe(false);
|
|
33
|
+
expect(isSalesWorkflowTool('quotes__')).toBe(false);
|
|
34
|
+
expect(isSalesWorkflowTool('project_brief__unknown')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('narrows the type when used as a type guard', () => {
|
|
38
|
+
const name: string = 'quotes__generate';
|
|
39
|
+
if (isSalesWorkflowTool(name)) {
|
|
40
|
+
// Inside this branch, TypeScript narrows `name` to `SalesWorkflowToolName`.
|
|
41
|
+
const narrowed: SalesWorkflowToolName = name;
|
|
42
|
+
expect(narrowed).toBe('quotes__generate');
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
package/tests/setup.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { SalesBot } from '../../src/vanilla/index'
|
|
3
|
+
import { SalesBotClient } from '../../src/core/client'
|
|
4
|
+
import { MemoryStorageAdapter } from '../../src/core/storage'
|
|
5
|
+
|
|
6
|
+
describe('SalesBot (vanilla entry)', () => {
|
|
7
|
+
it('SalesBot.init returns a SalesBotClient', () => {
|
|
8
|
+
const client = SalesBot.init({
|
|
9
|
+
embedKey: 'pk_live_vanilla_test',
|
|
10
|
+
baseUrl: 'http://localhost:3000',
|
|
11
|
+
storage: new MemoryStorageAdapter(),
|
|
12
|
+
})
|
|
13
|
+
expect(client).toBeInstanceOf(SalesBotClient)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('SalesBotClient from init has a visitor token', () => {
|
|
17
|
+
const client = SalesBot.init({
|
|
18
|
+
embedKey: 'pk_live_vanilla_test',
|
|
19
|
+
baseUrl: 'http://localhost:3000',
|
|
20
|
+
storage: new MemoryStorageAdapter(),
|
|
21
|
+
})
|
|
22
|
+
expect(client.getVisitorToken()).toMatch(/^[0-9a-f-]{36}$/i)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('SalesBot.widget mounts and returns a WidgetInstance', () => {
|
|
26
|
+
const instance = SalesBot.widget({
|
|
27
|
+
embedKey: 'pk_live_vanilla_test',
|
|
28
|
+
baseUrl: 'http://localhost:3000',
|
|
29
|
+
storage: new MemoryStorageAdapter(),
|
|
30
|
+
})
|
|
31
|
+
expect(typeof instance.open).toBe('function')
|
|
32
|
+
expect(typeof instance.close).toBe('function')
|
|
33
|
+
expect(typeof instance.toggle).toBe('function')
|
|
34
|
+
expect(typeof instance.destroy).toBe('function')
|
|
35
|
+
instance.destroy()
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import { defineComponent, nextTick } from 'vue'
|
|
4
|
+
import { useSalesBot } from '../../src/vue/use-sales-bot'
|
|
5
|
+
import { SalesBotError } from '../../src/core/types'
|
|
6
|
+
|
|
7
|
+
const EMBED_KEY = 'pk_live_vuetest'
|
|
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(([n, d]) => encodeSse(n, d)).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
|
+
vi.stubGlobal(
|
|
27
|
+
'fetch',
|
|
28
|
+
vi.fn().mockResolvedValueOnce(
|
|
29
|
+
new Response(makeSseStream(...events), {
|
|
30
|
+
status: 200,
|
|
31
|
+
headers: { 'content-type': 'text/event-stream' },
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mockFetchError(status: number, code: string, message: string, retryable = false) {
|
|
38
|
+
vi.stubGlobal(
|
|
39
|
+
'fetch',
|
|
40
|
+
vi.fn().mockResolvedValueOnce(
|
|
41
|
+
new Response(JSON.stringify({ code, message, retryable }), {
|
|
42
|
+
status,
|
|
43
|
+
headers: { 'content-type': 'application/json' },
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
50
|
+
|
|
51
|
+
/** Mount a component that uses the composable and exposes its return value */
|
|
52
|
+
function mountComposable() {
|
|
53
|
+
let exposed: ReturnType<typeof useSalesBot>
|
|
54
|
+
const Wrapper = defineComponent({
|
|
55
|
+
setup() {
|
|
56
|
+
exposed = useSalesBot({ embedKey: EMBED_KEY, baseUrl: BASE_URL })
|
|
57
|
+
return exposed
|
|
58
|
+
},
|
|
59
|
+
template: '<div></div>',
|
|
60
|
+
})
|
|
61
|
+
const wrapper = mount(Wrapper)
|
|
62
|
+
return { wrapper, composable: exposed! }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('useSalesBot (Vue)', () => {
|
|
66
|
+
it('returns reactive refs', () => {
|
|
67
|
+
const { composable } = mountComposable()
|
|
68
|
+
expect(composable.messages.value).toBeInstanceOf(Array)
|
|
69
|
+
expect(typeof composable.isStreaming.value).toBe('boolean')
|
|
70
|
+
expect(composable.error.value).toBeNull()
|
|
71
|
+
expect(typeof composable.ask).toBe('function')
|
|
72
|
+
expect(typeof composable.reset).toBe('function')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('starts with empty messages and not streaming', () => {
|
|
76
|
+
const { composable } = mountComposable()
|
|
77
|
+
expect(composable.messages.value).toHaveLength(0)
|
|
78
|
+
expect(composable.isStreaming.value).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('ask appends user message immediately', async () => {
|
|
82
|
+
mockFetchSSE(['done', { turnId: 't' }])
|
|
83
|
+
const { composable } = mountComposable()
|
|
84
|
+
await composable.ask('Hello!')
|
|
85
|
+
await nextTick()
|
|
86
|
+
const userMsg = composable.messages.value.find((m) => m.role === 'user')
|
|
87
|
+
expect(userMsg?.content).toBe('Hello!')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('messages ref updates with delta content', async () => {
|
|
91
|
+
mockFetchSSE(
|
|
92
|
+
['turn_started', { turnId: 't', conversationId: 'c', endUserId: 'e' }],
|
|
93
|
+
['delta', { content: 'Hey ' }],
|
|
94
|
+
['delta', { content: 'there' }],
|
|
95
|
+
['message_complete', { messageId: 'm', content: 'Hey there', modelId: 'x', promptTokens: 3, completionTokens: 3 }],
|
|
96
|
+
['done', { turnId: 't' }],
|
|
97
|
+
)
|
|
98
|
+
const { composable } = mountComposable()
|
|
99
|
+
await composable.ask('hi')
|
|
100
|
+
await nextTick()
|
|
101
|
+
|
|
102
|
+
const assistantMsg = composable.messages.value.find((m) => m.role === 'assistant')
|
|
103
|
+
expect(assistantMsg?.content).toBe('Hey there')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('conversationId ref set from turn_started', async () => {
|
|
107
|
+
mockFetchSSE(
|
|
108
|
+
['turn_started', { turnId: 't', conversationId: 'vue-conv', endUserId: 'e' }],
|
|
109
|
+
['done', { turnId: 't' }],
|
|
110
|
+
)
|
|
111
|
+
const { composable } = mountComposable()
|
|
112
|
+
await composable.ask('hi')
|
|
113
|
+
await nextTick()
|
|
114
|
+
expect(composable.conversationId.value).toBe('vue-conv')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('isStreaming transitions correctly', async () => {
|
|
118
|
+
mockFetchSSE(
|
|
119
|
+
['delta', { content: 'x' }],
|
|
120
|
+
['done', { turnId: 't' }],
|
|
121
|
+
)
|
|
122
|
+
const { composable } = mountComposable()
|
|
123
|
+
const askPromise = composable.ask('hi')
|
|
124
|
+
// isStreaming should be true while asking
|
|
125
|
+
expect(composable.isStreaming.value).toBe(true)
|
|
126
|
+
await askPromise
|
|
127
|
+
expect(composable.isStreaming.value).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('error ref is set on failure', async () => {
|
|
131
|
+
mockFetchError(429, 'rate_limited', 'Too fast', true)
|
|
132
|
+
const { composable } = mountComposable()
|
|
133
|
+
await composable.ask('hi')
|
|
134
|
+
await nextTick()
|
|
135
|
+
expect(composable.error.value).toBeInstanceOf(SalesBotError)
|
|
136
|
+
expect(composable.error.value?.code).toBe('rate_limited')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('reset clears messages, error, and conversationId', async () => {
|
|
140
|
+
mockFetchError(500, 'internal', 'oops')
|
|
141
|
+
const { composable } = mountComposable()
|
|
142
|
+
await composable.ask('hi')
|
|
143
|
+
await nextTick()
|
|
144
|
+
expect(composable.messages.value.length).toBeGreaterThan(0)
|
|
145
|
+
|
|
146
|
+
composable.reset()
|
|
147
|
+
await nextTick()
|
|
148
|
+
expect(composable.messages.value).toHaveLength(0)
|
|
149
|
+
expect(composable.error.value).toBeNull()
|
|
150
|
+
expect(composable.conversationId.value).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('unmounting unsubscribes handlers (no memory leak)', async () => {
|
|
154
|
+
mockFetchSSE(['done', { turnId: 't' }])
|
|
155
|
+
const { wrapper, composable } = mountComposable()
|
|
156
|
+
await composable.ask('hi')
|
|
157
|
+
|
|
158
|
+
// After unmount, handlers should be cleaned up
|
|
159
|
+
wrapper.unmount()
|
|
160
|
+
// No assertion on behavior — just verify it doesn't throw
|
|
161
|
+
expect(true).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
})
|