@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,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('<script>')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('escapes HTML inside list items too', () => {
|
|
91
|
+
const out = renderMarkdown('- <img onerror=foo>')
|
|
92
|
+
expect(out).toContain('<img onerror=foo>')
|
|
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
|
+
])
|
package/vitest.config.ts
ADDED
|
@@ -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
|
+
})
|