@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,113 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderMarkdown } from '../../src/widget/markdown'
3
+
4
+ describe('renderMarkdown', () => {
5
+ describe('inline', () => {
6
+ it('renders **bold**', () => {
7
+ expect(renderMarkdown('hello **world**')).toContain('<strong>world</strong>')
8
+ })
9
+
10
+ it('renders *italic*', () => {
11
+ expect(renderMarkdown('hello *world*')).toContain('<em>world</em>')
12
+ })
13
+
14
+ it('renders `code`', () => {
15
+ expect(renderMarkdown('use `pnpm test`')).toContain('<code>pnpm test</code>')
16
+ })
17
+
18
+ it('renders [text](https://url) as a target=_blank link', () => {
19
+ const out = renderMarkdown('see [docs](https://example.com/x)')
20
+ expect(out).toContain('<a href="https://example.com/x"')
21
+ expect(out).toContain('target="_blank"')
22
+ expect(out).toContain('rel="noopener noreferrer"')
23
+ })
24
+
25
+ it('does not render non-https links (anti-phishing)', () => {
26
+ const out = renderMarkdown('[click](http://evil.com)')
27
+ expect(out).not.toContain('<a')
28
+ })
29
+ })
30
+
31
+ describe('block: paragraphs', () => {
32
+ it('wraps a single line in <p>', () => {
33
+ expect(renderMarkdown('hello')).toBe('<p>hello</p>')
34
+ })
35
+
36
+ it('splits on blank lines into separate paragraphs', () => {
37
+ expect(renderMarkdown('first\n\nsecond')).toBe('<p>first</p><p>second</p>')
38
+ })
39
+
40
+ it('soft-breaks single newlines as <br> within a paragraph', () => {
41
+ expect(renderMarkdown('one\ntwo')).toBe('<p>one<br>two</p>')
42
+ })
43
+ })
44
+
45
+ describe('block: unordered lists', () => {
46
+ it('renders - lines as <ul><li>', () => {
47
+ const out = renderMarkdown('- apple\n- banana\n- cherry')
48
+ expect(out).toBe('<ul><li>apple</li><li>banana</li><li>cherry</li></ul>')
49
+ })
50
+
51
+ it('also accepts * as list bullet', () => {
52
+ const out = renderMarkdown('* one\n* two')
53
+ expect(out).toBe('<ul><li>one</li><li>two</li></ul>')
54
+ })
55
+
56
+ it('applies inline formatting inside list items', () => {
57
+ const out = renderMarkdown('- **bold** item\n- plain item')
58
+ expect(out).toContain('<li><strong>bold</strong> item</li>')
59
+ })
60
+ })
61
+
62
+ describe('block: ordered lists', () => {
63
+ it('renders 1. 2. as <ol><li>', () => {
64
+ const out = renderMarkdown('1. first\n2. second\n3. third')
65
+ expect(out).toBe('<ol><li>first</li><li>second</li><li>third</li></ol>')
66
+ })
67
+
68
+ it('renumbering does not break (uses sequential output regardless of source numbers)', () => {
69
+ const out = renderMarkdown('1. one\n5. five\n9. nine')
70
+ expect(out).toBe('<ol><li>one</li><li>five</li><li>nine</li></ol>')
71
+ })
72
+ })
73
+
74
+ describe('combined', () => {
75
+ it('mixes paragraphs and lists', () => {
76
+ const out = renderMarkdown('Here are options:\n\n- one\n- two\n\nThanks!')
77
+ expect(out).toContain('<p>Here are options:</p>')
78
+ expect(out).toContain('<ul><li>one</li><li>two</li></ul>')
79
+ expect(out).toContain('<p>Thanks!</p>')
80
+ })
81
+ })
82
+
83
+ describe('security', () => {
84
+ it('HTML-escapes raw input before applying transforms', () => {
85
+ const out = renderMarkdown('<script>alert(1)</script>')
86
+ expect(out).not.toContain('<script>')
87
+ expect(out).toContain('&lt;script&gt;')
88
+ })
89
+
90
+ it('escapes HTML inside list items too', () => {
91
+ const out = renderMarkdown('- <img onerror=foo>')
92
+ expect(out).toContain('&lt;img onerror=foo&gt;')
93
+ expect(out).not.toContain('<img')
94
+ })
95
+ })
96
+
97
+ describe('streaming tolerance', () => {
98
+ it('returns empty string for empty input', () => {
99
+ expect(renderMarkdown('')).toBe('')
100
+ })
101
+
102
+ it('handles unfinished bold without crashing', () => {
103
+ // Partial mid-stream input: opening ** without close
104
+ const out = renderMarkdown('starting **bold')
105
+ // The transform is non-greedy enough that an unclosed pair is left as-is
106
+ expect(out).toContain('starting')
107
+ })
108
+
109
+ it('handles a trailing blank line', () => {
110
+ expect(renderMarkdown('hello\n\n')).toBe('<p>hello</p>')
111
+ })
112
+ })
113
+ })
@@ -0,0 +1,388 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { createWidget } from '../../src/widget/widget'
3
+ import { MemoryStorageAdapter } from '../../src/core/storage'
4
+
5
+ const EMBED_KEY = 'pk_live_widgettest'
6
+
7
+ function encodeSse(name: string, data: unknown): string {
8
+ return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`
9
+ }
10
+
11
+ function mockFetchSSE(...events: Array<[string, unknown]>) {
12
+ const raw = events.map(([n, d]) => encodeSse(n, d)).join('')
13
+ const encoder = new TextEncoder()
14
+ vi.stubGlobal(
15
+ 'fetch',
16
+ vi.fn().mockResolvedValueOnce(
17
+ new Response(
18
+ new ReadableStream({
19
+ start(ctrl) { ctrl.enqueue(encoder.encode(raw)); ctrl.close() },
20
+ }),
21
+ { status: 200, headers: { 'content-type': 'text/event-stream' } },
22
+ ),
23
+ ),
24
+ )
25
+ }
26
+
27
+ function mockBotConfigFetch(config: { name: string; greetingMessage: string | null }) {
28
+ vi.stubGlobal(
29
+ 'fetch',
30
+ vi.fn().mockResolvedValueOnce(
31
+ new Response(JSON.stringify(config), {
32
+ status: 200,
33
+ headers: { 'content-type': 'application/json' },
34
+ }),
35
+ ),
36
+ )
37
+ }
38
+
39
+ afterEach(() => vi.unstubAllGlobals())
40
+
41
+ describe('createWidget', () => {
42
+ it('appends a <sales-bot-widget> element to the container', () => {
43
+ const container = document.createElement('div')
44
+ const instance = createWidget({
45
+ embedKey: EMBED_KEY,
46
+ storage: new MemoryStorageAdapter(),
47
+ container,
48
+ })
49
+ expect(container.querySelector('sales-bot-widget')).not.toBeNull()
50
+ instance.destroy()
51
+ })
52
+
53
+ it('attaches a shadow root', () => {
54
+ const container = document.createElement('div')
55
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
56
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
57
+ expect(el.shadowRoot).not.toBeNull()
58
+ instance.destroy()
59
+ })
60
+
61
+ it('shadow root contains a launcher button', () => {
62
+ const container = document.createElement('div')
63
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
64
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
65
+ const launcher = el.shadowRoot!.querySelector('.sb-launcher')
66
+ expect(launcher).not.toBeNull()
67
+ instance.destroy()
68
+ })
69
+
70
+ it('shadow root contains a panel (hidden by default)', () => {
71
+ const container = document.createElement('div')
72
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
73
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
74
+ const panel = el.shadowRoot!.querySelector('.sb-panel') as HTMLElement
75
+ expect(panel).not.toBeNull()
76
+ expect(panel.classList.contains('sb-panel--open')).toBe(false)
77
+ instance.destroy()
78
+ })
79
+
80
+ it('open() adds sb-panel--open class', () => {
81
+ const container = document.createElement('div')
82
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
83
+ instance.open()
84
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
85
+ const panel = el.shadowRoot!.querySelector('.sb-panel') as HTMLElement
86
+ expect(panel.classList.contains('sb-panel--open')).toBe(true)
87
+ instance.destroy()
88
+ })
89
+
90
+ it('close() removes sb-panel--open class', () => {
91
+ const container = document.createElement('div')
92
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
93
+ instance.open()
94
+ instance.close()
95
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
96
+ const panel = el.shadowRoot!.querySelector('.sb-panel') as HTMLElement
97
+ expect(panel.classList.contains('sb-panel--open')).toBe(false)
98
+ instance.destroy()
99
+ })
100
+
101
+ describe('greeting', () => {
102
+ it('shows the bot greetingMessage on first open of a fresh conversation', async () => {
103
+ mockBotConfigFetch({ name: 'Vezert', greetingMessage: 'Hi! Welcome to Vezert.' })
104
+ const container = document.createElement('div')
105
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
106
+ instance.open()
107
+ // Allow the awaited getBotConfig() in maybeShowGreeting to resolve.
108
+ await new Promise((r) => setTimeout(r, 0))
109
+
110
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
111
+ const messages = el.shadowRoot!.querySelector('.sb-messages')!
112
+ const assistantBubbles = messages.querySelectorAll('.sb-message--assistant')
113
+ expect(assistantBubbles.length).toBe(1)
114
+ expect(assistantBubbles[0]!.textContent).toContain('Welcome to Vezert')
115
+
116
+ instance.destroy()
117
+ })
118
+
119
+ it('does not show greeting when continuing a persisted conversation', async () => {
120
+ const storage = new MemoryStorageAdapter()
121
+ // Seed a persisted conversationId — simulates a page refresh on an
122
+ // existing conversation.
123
+ storage.setItem(`salesbot:conversation:${EMBED_KEY}`, 'conv-existing')
124
+
125
+ mockBotConfigFetch({ name: 'Vezert', greetingMessage: 'Welcome!' })
126
+ const container = document.createElement('div')
127
+ const instance = createWidget({ embedKey: EMBED_KEY, storage, container })
128
+ instance.open()
129
+ await new Promise((r) => setTimeout(r, 0))
130
+
131
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
132
+ const messages = el.shadowRoot!.querySelector('.sb-messages')!
133
+ expect(messages.querySelectorAll('.sb-message--assistant').length).toBe(0)
134
+
135
+ instance.destroy()
136
+ })
137
+
138
+ it('only fires once even when the widget is opened and closed repeatedly', async () => {
139
+ mockBotConfigFetch({ name: 'Vezert', greetingMessage: 'Welcome!' })
140
+ const container = document.createElement('div')
141
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
142
+
143
+ instance.open()
144
+ await new Promise((r) => setTimeout(r, 0))
145
+ instance.close()
146
+ instance.open()
147
+ await new Promise((r) => setTimeout(r, 0))
148
+ instance.open()
149
+ await new Promise((r) => setTimeout(r, 0))
150
+
151
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
152
+ const assistantBubbles = el.shadowRoot!.querySelectorAll('.sb-message--assistant')
153
+ expect(assistantBubbles.length).toBe(1)
154
+
155
+ instance.destroy()
156
+ })
157
+
158
+ it('silently skips greeting when bot-config fetch fails', async () => {
159
+ vi.stubGlobal(
160
+ 'fetch',
161
+ vi.fn().mockRejectedValueOnce(new Error('network down')),
162
+ )
163
+ const container = document.createElement('div')
164
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
165
+ instance.open()
166
+ await new Promise((r) => setTimeout(r, 0))
167
+
168
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
169
+ const messages = el.shadowRoot!.querySelector('.sb-messages')!
170
+ // No assistant bubble, no error bubble — silent skip.
171
+ expect(messages.querySelectorAll('.sb-message--assistant').length).toBe(0)
172
+ expect(messages.querySelectorAll('.sb-message--error').length).toBe(0)
173
+
174
+ instance.destroy()
175
+ })
176
+
177
+ it('skips greeting when greetingMessage is null or empty', async () => {
178
+ mockBotConfigFetch({ name: 'Vezert', greetingMessage: null })
179
+ const container = document.createElement('div')
180
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
181
+ instance.open()
182
+ await new Promise((r) => setTimeout(r, 0))
183
+
184
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
185
+ const messages = el.shadowRoot!.querySelector('.sb-messages')!
186
+ expect(messages.querySelectorAll('.sb-message--assistant').length).toBe(0)
187
+
188
+ instance.destroy()
189
+ })
190
+ })
191
+
192
+ it('toggle() opens and closes', () => {
193
+ const container = document.createElement('div')
194
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
195
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
196
+ const panel = el.shadowRoot!.querySelector('.sb-panel') as HTMLElement
197
+ instance.toggle()
198
+ expect(panel.classList.contains('sb-panel--open')).toBe(true)
199
+ instance.toggle()
200
+ expect(panel.classList.contains('sb-panel--open')).toBe(false)
201
+ instance.destroy()
202
+ })
203
+
204
+ it('destroy() removes the element from the container', () => {
205
+ const container = document.createElement('div')
206
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
207
+ instance.destroy()
208
+ expect(container.querySelector('sales-bot-widget')).toBeNull()
209
+ })
210
+
211
+ it('defaults to document.body as container', () => {
212
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter() })
213
+ expect(document.body.querySelector('sales-bot-widget')).not.toBeNull()
214
+ instance.destroy()
215
+ })
216
+
217
+ describe('theming', () => {
218
+ it('applies all WidgetTheme keys as --sb-<kebab-case> custom properties on the host', () => {
219
+ const container = document.createElement('div')
220
+ const instance = createWidget({
221
+ embedKey: EMBED_KEY,
222
+ storage: new MemoryStorageAdapter(),
223
+ container,
224
+ theme: {
225
+ primary: '#ff0080',
226
+ primaryHover: '#cc006e',
227
+ primaryText: '#fffaf0',
228
+ text: '#222',
229
+ textLight: '#888',
230
+ bg: '#fafafa',
231
+ bgUser: '#ff0080',
232
+ bgAssistant: '#eef',
233
+ bgError: '#fde8e8',
234
+ textError: '#a91111',
235
+ border: '#ddd',
236
+ focusRing: 'rgba(255,0,128,0.4)',
237
+ radius: '20px',
238
+ messageRadius: '24px',
239
+ inputRadius: '28px',
240
+ z: 12345,
241
+ launcherSize: '64px',
242
+ sendSize: '44px',
243
+ panelWidth: '420px',
244
+ panelHeight: '640px',
245
+ panelMaxHeight: '90vh',
246
+ bottomOffset: '32px',
247
+ sideOffset: '32px',
248
+ panelGap: '16px',
249
+ fontFamily: '"Inter", sans-serif',
250
+ fontSize: '15px',
251
+ fontSizeHeader: '17px',
252
+ fontSizeSmall: '12px',
253
+ shadow: '0 8px 32px rgba(0,0,0,0.2)',
254
+ transition: '0.2s cubic-bezier(0.4, 0, 0.2, 1)',
255
+ },
256
+ })
257
+
258
+ const host = container.querySelector('sales-bot-widget') as HTMLElement
259
+
260
+ // Spot-check a handful covering each category. The mapping is the same
261
+ // for all keys (camelCase → kebab-case with --sb- prefix).
262
+ expect(host.style.getPropertyValue('--sb-primary')).toBe('#ff0080')
263
+ expect(host.style.getPropertyValue('--sb-primary-hover')).toBe('#cc006e')
264
+ expect(host.style.getPropertyValue('--sb-primary-text')).toBe('#fffaf0')
265
+ expect(host.style.getPropertyValue('--sb-bg-error')).toBe('#fde8e8')
266
+ expect(host.style.getPropertyValue('--sb-text-error')).toBe('#a91111')
267
+ expect(host.style.getPropertyValue('--sb-radius')).toBe('20px')
268
+ expect(host.style.getPropertyValue('--sb-message-radius')).toBe('24px')
269
+ expect(host.style.getPropertyValue('--sb-input-radius')).toBe('28px')
270
+ expect(host.style.getPropertyValue('--sb-z')).toBe('12345')
271
+ expect(host.style.getPropertyValue('--sb-launcher-size')).toBe('64px')
272
+ expect(host.style.getPropertyValue('--sb-send-size')).toBe('44px')
273
+ expect(host.style.getPropertyValue('--sb-panel-width')).toBe('420px')
274
+ expect(host.style.getPropertyValue('--sb-panel-height')).toBe('640px')
275
+ expect(host.style.getPropertyValue('--sb-panel-max-height')).toBe('90vh')
276
+ expect(host.style.getPropertyValue('--sb-bottom-offset')).toBe('32px')
277
+ expect(host.style.getPropertyValue('--sb-side-offset')).toBe('32px')
278
+ expect(host.style.getPropertyValue('--sb-panel-gap')).toBe('16px')
279
+ expect(host.style.getPropertyValue('--sb-font-family')).toBe('"Inter", sans-serif')
280
+ expect(host.style.getPropertyValue('--sb-font-size')).toBe('15px')
281
+ expect(host.style.getPropertyValue('--sb-font-size-header')).toBe('17px')
282
+ expect(host.style.getPropertyValue('--sb-font-size-small')).toBe('12px')
283
+ expect(host.style.getPropertyValue('--sb-shadow')).toBe('0 8px 32px rgba(0,0,0,0.2)')
284
+ expect(host.style.getPropertyValue('--sb-transition')).toBe('0.2s cubic-bezier(0.4, 0, 0.2, 1)')
285
+ expect(host.style.getPropertyValue('--sb-focus-ring')).toBe('rgba(255,0,128,0.4)')
286
+
287
+ instance.destroy()
288
+ })
289
+
290
+ it('only sets vars for provided keys; omitted keys inherit defaults', () => {
291
+ const container = document.createElement('div')
292
+ const instance = createWidget({
293
+ embedKey: EMBED_KEY,
294
+ storage: new MemoryStorageAdapter(),
295
+ container,
296
+ theme: { primary: '#abcdef' },
297
+ })
298
+ const host = container.querySelector('sales-bot-widget') as HTMLElement
299
+ expect(host.style.getPropertyValue('--sb-primary')).toBe('#abcdef')
300
+ // Not provided → no inline override → falls back to the CSS default in :host
301
+ expect(host.style.getPropertyValue('--sb-bg')).toBe('')
302
+ instance.destroy()
303
+ })
304
+
305
+ it('theme.primary overrides legacy primaryColor when both are provided', () => {
306
+ const container = document.createElement('div')
307
+ const instance = createWidget({
308
+ embedKey: EMBED_KEY,
309
+ storage: new MemoryStorageAdapter(),
310
+ container,
311
+ primaryColor: '#111111',
312
+ theme: { primary: '#999999' },
313
+ })
314
+ const host = container.querySelector('sales-bot-widget') as HTMLElement
315
+ expect(host.style.getPropertyValue('--sb-primary')).toBe('#999999')
316
+ instance.destroy()
317
+ })
318
+
319
+ it('injects customCss into the shadow root after the base stylesheet', () => {
320
+ const container = document.createElement('div')
321
+ const instance = createWidget({
322
+ embedKey: EMBED_KEY,
323
+ storage: new MemoryStorageAdapter(),
324
+ container,
325
+ customCss: '.sb-launcher { background: lime; }',
326
+ })
327
+ const host = container.querySelector('sales-bot-widget') as HTMLElement
328
+ const styles = host.shadowRoot!.querySelectorAll('style')
329
+ // First style is the built-in WIDGET_CSS; second is the user CSS.
330
+ expect(styles.length).toBeGreaterThanOrEqual(2)
331
+ const custom = host.shadowRoot!.querySelector('style[data-sb-custom]') as HTMLStyleElement
332
+ expect(custom).not.toBeNull()
333
+ expect(custom.textContent).toContain('.sb-launcher { background: lime; }')
334
+ // Ordering: custom must appear AFTER the built-in so cascade wins.
335
+ const all = Array.from(host.shadowRoot!.querySelectorAll('style'))
336
+ expect(all.indexOf(custom)).toBeGreaterThan(0)
337
+ instance.destroy()
338
+ })
339
+
340
+ it('skips customCss style element when option is empty or whitespace', () => {
341
+ const container = document.createElement('div')
342
+ const instance = createWidget({
343
+ embedKey: EMBED_KEY,
344
+ storage: new MemoryStorageAdapter(),
345
+ container,
346
+ customCss: ' ',
347
+ })
348
+ const host = container.querySelector('sales-bot-widget') as HTMLElement
349
+ const custom = host.shadowRoot!.querySelector('style[data-sb-custom]')
350
+ expect(custom).toBeNull()
351
+ instance.destroy()
352
+ })
353
+ })
354
+
355
+ it('contains a message list and input in shadow root', () => {
356
+ const container = document.createElement('div')
357
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
358
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
359
+ expect(el.shadowRoot!.querySelector('.sb-messages')).not.toBeNull()
360
+ expect(el.shadowRoot!.querySelector('.sb-input')).not.toBeNull()
361
+ expect(el.shadowRoot!.querySelector('.sb-send')).not.toBeNull()
362
+ instance.destroy()
363
+ })
364
+
365
+ it('sends a message when form is submitted', async () => {
366
+ mockFetchSSE(['done', { turnId: 't' }])
367
+ const container = document.createElement('div')
368
+ document.body.appendChild(container)
369
+ const instance = createWidget({ embedKey: EMBED_KEY, storage: new MemoryStorageAdapter(), container })
370
+ instance.open()
371
+
372
+ const el = container.querySelector('sales-bot-widget') as HTMLElement
373
+ const input = el.shadowRoot!.querySelector('.sb-input') as HTMLInputElement
374
+ const sendBtn = el.shadowRoot!.querySelector('.sb-send') as HTMLButtonElement
375
+
376
+ input.value = 'Test message'
377
+ sendBtn.click()
378
+
379
+ // Wait for the async ask to complete
380
+ await new Promise((r) => setTimeout(r, 50))
381
+
382
+ const messages = el.shadowRoot!.querySelectorAll('.sb-message')
383
+ expect(messages.length).toBeGreaterThan(0)
384
+
385
+ instance.destroy()
386
+ document.body.removeChild(container)
387
+ })
388
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "noImplicitOverride": true,
11
+ "noPropertyAccessFromIndexSignature": false,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true,
17
+ "outDir": "./dist",
18
+ "paths": {
19
+ "@sales-bot/sdk/core": ["./src/core/index.ts"],
20
+ "@sales-bot/sdk/react": ["./src/react/index.ts"],
21
+ "@sales-bot/sdk/vue": ["./src/vue/index.ts"],
22
+ "@sales-bot/sdk/widget": ["./src/widget/index.ts"],
23
+ "@sales-bot/sdk/vanilla": ["./src/vanilla/index.ts"]
24
+ }
25
+ },
26
+ "include": ["src/**/*", "tests/**/*"],
27
+ "exclude": ["node_modules", "dist"]
28
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { defineConfig } from 'tsup'
2
+
3
+ export default defineConfig([
4
+ // ESM + CJS bundles for core, react, vue, widget
5
+ {
6
+ entry: {
7
+ core: 'src/core/index.ts',
8
+ react: 'src/react/index.ts',
9
+ vue: 'src/vue/index.ts',
10
+ widget: 'src/widget/index.ts',
11
+ },
12
+ format: ['esm', 'cjs'],
13
+ dts: true,
14
+ sourcemap: true,
15
+ clean: true,
16
+ treeshake: true,
17
+ splitting: false,
18
+ external: ['react', 'react-dom', 'vue'],
19
+ esbuildOptions(options) {
20
+ options.target = 'es2020'
21
+ },
22
+ },
23
+ // IIFE bundle for vanilla <script> usage
24
+ {
25
+ entry: {
26
+ vanilla: 'src/vanilla/index.ts',
27
+ },
28
+ format: ['iife'],
29
+ globalName: 'SalesBot',
30
+ minify: true,
31
+ dts: false,
32
+ sourcemap: false,
33
+ treeshake: true,
34
+ esbuildOptions(options) {
35
+ options.target = 'es2018'
36
+ },
37
+ },
38
+ ])
@@ -0,0 +1,26 @@
1
+ import { defineConfig } from 'vitest/config'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ test: {
7
+ environment: 'happy-dom',
8
+ globals: true,
9
+ include: ['tests/**/*.test.{ts,tsx}'],
10
+ coverage: {
11
+ provider: 'v8',
12
+ reporter: ['text', 'json', 'html'],
13
+ include: ['src/**/*.{ts,tsx}'],
14
+ exclude: ['src/**/index.ts'],
15
+ },
16
+ setupFiles: ['tests/setup.ts'],
17
+ },
18
+ resolve: {
19
+ alias: {
20
+ '@sales-bot/sdk/core': '/Users/artemzaitsev/projects/chaindoc/sales_bot_sdk/src/core/index.ts',
21
+ '@sales-bot/sdk/react': '/Users/artemzaitsev/projects/chaindoc/sales_bot_sdk/src/react/index.ts',
22
+ '@sales-bot/sdk/vue': '/Users/artemzaitsev/projects/chaindoc/sales_bot_sdk/src/vue/index.ts',
23
+ '@sales-bot/sdk/widget': '/Users/artemzaitsev/projects/chaindoc/sales_bot_sdk/src/widget/index.ts',
24
+ },
25
+ },
26
+ })