polymorph-sdk 0.2.2 → 0.2.4
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/dist/index.css +1 -1
- package/dist/index.d.ts +56 -19
- package/dist/index.js +6338 -5937
- package/package.json +1 -1
- package/src/ChatThread.tsx +42 -9
- package/src/IdentityForm.tsx +135 -0
- package/src/PolymorphWidget.tsx +68 -32
- package/src/RoomHandler.tsx +22 -2
- package/src/VoiceOverlay.tsx +60 -11
- package/src/WidgetPanel.tsx +103 -74
- package/src/__tests__/IdentityForm.test.tsx +146 -0
- package/src/__tests__/PolymorphWidget.test.tsx +173 -0
- package/src/__tests__/integration.test.ts +58 -0
- package/src/__tests__/usePolymorphSession.test.ts +422 -0
- package/src/index.ts +4 -1
- package/src/styles.module.css +203 -67
- package/src/types.ts +39 -16
- package/src/usePolymorphSession.ts +360 -61
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { ResolvedWidgetConfig } from "../types";
|
|
4
|
+
import { usePolymorphSession } from "../usePolymorphSession";
|
|
5
|
+
|
|
6
|
+
const MOCK_CONFIG: ResolvedWidgetConfig = {
|
|
7
|
+
id: "cfg-1",
|
|
8
|
+
title: "Test Widget",
|
|
9
|
+
subtitle: "Sub",
|
|
10
|
+
primaryColor: "#000",
|
|
11
|
+
position: "bottom-right",
|
|
12
|
+
darkMode: false,
|
|
13
|
+
enableVoice: true,
|
|
14
|
+
greeting: "Hello!",
|
|
15
|
+
collectEmail: "hidden",
|
|
16
|
+
collectPhone: "hidden",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function mockResolveResponse(cfg = MOCK_CONFIG) {
|
|
20
|
+
return {
|
|
21
|
+
id: cfg.id,
|
|
22
|
+
title: cfg.title,
|
|
23
|
+
subtitle: cfg.subtitle,
|
|
24
|
+
primary_color: cfg.primaryColor,
|
|
25
|
+
position: cfg.position,
|
|
26
|
+
dark_mode: cfg.darkMode,
|
|
27
|
+
enable_voice: cfg.enableVoice,
|
|
28
|
+
greeting: cfg.greeting,
|
|
29
|
+
collect_email: cfg.collectEmail,
|
|
30
|
+
collect_phone: cfg.collectPhone,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function roomResponse(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
json: () =>
|
|
38
|
+
Promise.resolve({
|
|
39
|
+
token: "tok-123",
|
|
40
|
+
livekit_url: "wss://lk.example.com",
|
|
41
|
+
...overrides,
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
fetchMock = vi.fn();
|
|
50
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
51
|
+
fetchMock.mockImplementation((url: string) => {
|
|
52
|
+
if (url.includes("/widget-configs/resolve")) {
|
|
53
|
+
return Promise.resolve({
|
|
54
|
+
ok: true,
|
|
55
|
+
json: () => Promise.resolve(mockResolveResponse()),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// Default: reject unknown URLs
|
|
59
|
+
return Promise.resolve({
|
|
60
|
+
ok: false,
|
|
61
|
+
status: 404,
|
|
62
|
+
text: () => Promise.resolve("Not found"),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
sessionStorage.clear();
|
|
66
|
+
localStorage.clear();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
cleanup();
|
|
71
|
+
vi.restoreAllMocks();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("usePolymorphSession", () => {
|
|
75
|
+
// ── Config fetch on mount ──
|
|
76
|
+
|
|
77
|
+
it("starts with idle status, empty messages, null roomConnection", () => {
|
|
78
|
+
const { result } = renderHook(() =>
|
|
79
|
+
usePolymorphSession({ apiBaseUrl: "http://test", apiKey: "key" }),
|
|
80
|
+
);
|
|
81
|
+
expect(result.current.status).toBe("idle");
|
|
82
|
+
expect(result.current.messages).toEqual([]);
|
|
83
|
+
expect(result.current.roomConnection).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("fetches /widget-configs/resolve on mount and sets resolvedConfig", async () => {
|
|
87
|
+
const { result } = renderHook(() =>
|
|
88
|
+
usePolymorphSession({ apiBaseUrl: "http://test", apiKey: "key" }),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(result.current.resolvedConfig).not.toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(result.current.resolvedConfig?.id).toBe("cfg-1");
|
|
96
|
+
expect(result.current.resolvedConfig?.title).toBe("Test Widget");
|
|
97
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
98
|
+
"http://test/widget-configs/resolve",
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
headers: expect.objectContaining({
|
|
101
|
+
Authorization: "Bearer key",
|
|
102
|
+
}),
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("adds greeting message from resolved config", async () => {
|
|
108
|
+
const { result } = renderHook(() =>
|
|
109
|
+
usePolymorphSession({ apiBaseUrl: "http://test", apiKey: "key" }),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await waitFor(() => {
|
|
113
|
+
expect(result.current.messages).toHaveLength(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.current.messages[0].role).toBe("agent");
|
|
117
|
+
expect(result.current.messages[0].text).toBe("Hello!");
|
|
118
|
+
expect(result.current.messages[0].id).toBe("greeting");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("appends ?config_id=X when configId prop is set", async () => {
|
|
122
|
+
const { result } = renderHook(() =>
|
|
123
|
+
usePolymorphSession({
|
|
124
|
+
apiBaseUrl: "http://test",
|
|
125
|
+
apiKey: "key",
|
|
126
|
+
configId: "my-config",
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
expect(result.current.resolvedConfig).not.toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
135
|
+
"http://test/widget-configs/resolve?config_id=my-config",
|
|
136
|
+
expect.anything(),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── Connect flow ──
|
|
141
|
+
|
|
142
|
+
it("connect() sets roomConnection on success", async () => {
|
|
143
|
+
fetchMock.mockImplementation((url: string) => {
|
|
144
|
+
if (url.includes("/widget-configs/resolve")) {
|
|
145
|
+
return Promise.resolve({
|
|
146
|
+
ok: true,
|
|
147
|
+
json: () => Promise.resolve(mockResolveResponse()),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (url.includes("/voice-rooms/start")) {
|
|
151
|
+
return Promise.resolve(roomResponse());
|
|
152
|
+
}
|
|
153
|
+
return Promise.resolve({
|
|
154
|
+
ok: false,
|
|
155
|
+
status: 404,
|
|
156
|
+
text: () => Promise.resolve(""),
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const { result } = renderHook(() =>
|
|
161
|
+
usePolymorphSession({ apiBaseUrl: "http://test", apiKey: "key" }),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(result.current.resolvedConfig).not.toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
act(() => {
|
|
169
|
+
void result.current.connect();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await waitFor(() => {
|
|
173
|
+
expect(result.current.roomConnection).not.toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(result.current.roomConnection).toEqual({
|
|
177
|
+
token: "tok-123",
|
|
178
|
+
livekitUrl: "wss://lk.example.com",
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("connect() is a no-op when already connecting", async () => {
|
|
183
|
+
let resolvePost!: (v: unknown) => void;
|
|
184
|
+
fetchMock.mockImplementation((url: string) => {
|
|
185
|
+
if (url.includes("/widget-configs/resolve")) {
|
|
186
|
+
return Promise.resolve({
|
|
187
|
+
ok: true,
|
|
188
|
+
json: () => Promise.resolve(mockResolveResponse()),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (url.includes("/voice-rooms/start")) {
|
|
192
|
+
return new Promise((r) => {
|
|
193
|
+
resolvePost = r;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return Promise.resolve({
|
|
197
|
+
ok: false,
|
|
198
|
+
status: 404,
|
|
199
|
+
text: () => Promise.resolve(""),
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const { result } = renderHook(() =>
|
|
204
|
+
usePolymorphSession({ apiBaseUrl: "http://test", apiKey: "key" }),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await waitFor(() => {
|
|
208
|
+
expect(result.current.resolvedConfig).not.toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const _callCountBefore = fetchMock.mock.calls.length;
|
|
212
|
+
|
|
213
|
+
act(() => {
|
|
214
|
+
void result.current.connect();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
expect(result.current.status).toBe("connecting");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Second connect should be no-op
|
|
222
|
+
act(() => {
|
|
223
|
+
void result.current.connect();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Only one additional fetch call (the POST) should have been made
|
|
227
|
+
const postCalls = fetchMock.mock.calls.filter((c: string[]) =>
|
|
228
|
+
c[0].includes("/voice-rooms/start"),
|
|
229
|
+
);
|
|
230
|
+
expect(postCalls).toHaveLength(1);
|
|
231
|
+
|
|
232
|
+
// Cleanup
|
|
233
|
+
resolvePost(roomResponse());
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("failed /voice-rooms/start sets status to error", async () => {
|
|
237
|
+
fetchMock.mockImplementation((url: string) => {
|
|
238
|
+
if (url.includes("/widget-configs/resolve")) {
|
|
239
|
+
return Promise.resolve({
|
|
240
|
+
ok: true,
|
|
241
|
+
json: () => Promise.resolve(mockResolveResponse()),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (url.includes("/voice-rooms/start")) {
|
|
245
|
+
return Promise.resolve({
|
|
246
|
+
ok: false,
|
|
247
|
+
status: 500,
|
|
248
|
+
text: () => Promise.resolve("Internal Server Error"),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return Promise.resolve({
|
|
252
|
+
ok: false,
|
|
253
|
+
status: 404,
|
|
254
|
+
text: () => Promise.resolve(""),
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const { result } = renderHook(() =>
|
|
259
|
+
usePolymorphSession({ apiBaseUrl: "http://test", apiKey: "key" }),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
await waitFor(() => {
|
|
263
|
+
expect(result.current.resolvedConfig).not.toBeNull();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
act(() => {
|
|
267
|
+
void result.current.connect();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await waitFor(() => {
|
|
271
|
+
expect(result.current.status).toBe("error");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(result.current.error).toBe("Internal Server Error");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ── Messages ──
|
|
278
|
+
|
|
279
|
+
it("sendMessage adds a user message", async () => {
|
|
280
|
+
fetchMock.mockImplementation((url: string) => {
|
|
281
|
+
if (url.includes("/widget-configs/resolve")) {
|
|
282
|
+
return Promise.resolve({
|
|
283
|
+
ok: true,
|
|
284
|
+
json: () => Promise.resolve(mockResolveResponse()),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if (url.includes("/voice-rooms/start")) {
|
|
288
|
+
return Promise.resolve(roomResponse());
|
|
289
|
+
}
|
|
290
|
+
return Promise.resolve({
|
|
291
|
+
ok: false,
|
|
292
|
+
status: 404,
|
|
293
|
+
text: () => Promise.resolve(""),
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const { result } = renderHook(() =>
|
|
298
|
+
usePolymorphSession({ apiBaseUrl: "http://test", apiKey: "key" }),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
await waitFor(() => {
|
|
302
|
+
expect(result.current.messages).toHaveLength(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
act(() => {
|
|
306
|
+
result.current.sendMessage("Hi there");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(result.current.messages).toHaveLength(2);
|
|
310
|
+
expect(result.current.messages[1].role).toBe("user");
|
|
311
|
+
expect(result.current.messages[1].text).toBe("Hi there");
|
|
312
|
+
expect(result.current.messages[1].source).toBe("chat");
|
|
313
|
+
expect(result.current.messages[1].id).toBeTruthy();
|
|
314
|
+
expect(result.current.messages[1].timestamp).toBeGreaterThan(0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("messages array grows with multiple sends", async () => {
|
|
318
|
+
fetchMock.mockImplementation((url: string) => {
|
|
319
|
+
if (url.includes("/widget-configs/resolve")) {
|
|
320
|
+
return Promise.resolve({
|
|
321
|
+
ok: true,
|
|
322
|
+
json: () => Promise.resolve(mockResolveResponse()),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (url.includes("/voice-rooms/start")) {
|
|
326
|
+
return Promise.resolve(roomResponse());
|
|
327
|
+
}
|
|
328
|
+
return Promise.resolve({
|
|
329
|
+
ok: false,
|
|
330
|
+
status: 404,
|
|
331
|
+
text: () => Promise.resolve(""),
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const { result } = renderHook(() =>
|
|
336
|
+
usePolymorphSession({ apiBaseUrl: "http://test", apiKey: "key" }),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
await waitFor(() => {
|
|
340
|
+
expect(result.current.messages).toHaveLength(1);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
act(() => {
|
|
344
|
+
result.current.sendMessage("msg 1");
|
|
345
|
+
});
|
|
346
|
+
act(() => {
|
|
347
|
+
result.current.sendMessage("msg 2");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(result.current.messages).toHaveLength(3);
|
|
351
|
+
expect(result.current.messages[1].text).toBe("msg 1");
|
|
352
|
+
expect(result.current.messages[2].text).toBe("msg 2");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ── Disconnect ──
|
|
356
|
+
|
|
357
|
+
it("disconnect resets status to idle and clears roomConnection", async () => {
|
|
358
|
+
fetchMock.mockImplementation((url: string) => {
|
|
359
|
+
if (url.includes("/widget-configs/resolve")) {
|
|
360
|
+
return Promise.resolve({
|
|
361
|
+
ok: true,
|
|
362
|
+
json: () => Promise.resolve(mockResolveResponse()),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (url.includes("/voice-rooms/start")) {
|
|
366
|
+
return Promise.resolve(roomResponse());
|
|
367
|
+
}
|
|
368
|
+
return Promise.resolve({
|
|
369
|
+
ok: false,
|
|
370
|
+
status: 404,
|
|
371
|
+
text: () => Promise.resolve(""),
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const { result } = renderHook(() =>
|
|
376
|
+
usePolymorphSession({ apiBaseUrl: "http://test", apiKey: "key" }),
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
await waitFor(() => {
|
|
380
|
+
expect(result.current.resolvedConfig).not.toBeNull();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
act(() => {
|
|
384
|
+
void result.current.connect();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await waitFor(() => {
|
|
388
|
+
expect(result.current.roomConnection).not.toBeNull();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
act(() => {
|
|
392
|
+
result.current.disconnect();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
expect(result.current.status).toBe("idle");
|
|
396
|
+
expect(result.current.roomConnection).toBeNull();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ── addMessage ──
|
|
400
|
+
|
|
401
|
+
it("addMessage appends messages with correct role and source", async () => {
|
|
402
|
+
const { result } = renderHook(() =>
|
|
403
|
+
usePolymorphSession({ apiBaseUrl: "http://test", apiKey: "key" }),
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
await waitFor(() => {
|
|
407
|
+
expect(result.current.messages).toHaveLength(1);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
act(() => {
|
|
411
|
+
result.current.addMessage("agent", "Agent reply", "voice", "Agent");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(result.current.messages).toHaveLength(2);
|
|
415
|
+
expect(result.current.messages[1]).toMatchObject({
|
|
416
|
+
role: "agent",
|
|
417
|
+
text: "Agent reply",
|
|
418
|
+
source: "voice",
|
|
419
|
+
senderName: "Agent",
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
export { PolymorphWidget } from "./PolymorphWidget";
|
|
2
2
|
export type {
|
|
3
3
|
ChatMessage,
|
|
4
|
+
FieldRequirement,
|
|
5
|
+
IdentityCollection,
|
|
6
|
+
ResolvedWidgetConfig,
|
|
4
7
|
SessionStatus,
|
|
5
|
-
WidgetBranding,
|
|
6
8
|
WidgetConfig,
|
|
9
|
+
WidgetUser,
|
|
7
10
|
} from "./types";
|
|
8
11
|
export { usePolymorphSession } from "./usePolymorphSession";
|