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
package/src/WidgetPanel.tsx
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import type { Room } from "livekit-client";
|
|
2
|
-
import { useCallback, useState } from "react";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
3
|
import { ChatThread } from "./ChatThread";
|
|
4
|
+
import { IdentityForm } from "./IdentityForm";
|
|
4
5
|
import styles from "./styles.module.css";
|
|
5
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
ChatMessage,
|
|
8
|
+
IdentityCollection,
|
|
9
|
+
ResolvedWidgetConfig,
|
|
10
|
+
SessionStatus,
|
|
11
|
+
WidgetUser,
|
|
12
|
+
} from "./types";
|
|
6
13
|
import { VoiceOverlay } from "./VoiceOverlay";
|
|
7
14
|
|
|
8
|
-
// Internal SVG icons (no @tabler dependency)
|
|
9
15
|
function MicIcon() {
|
|
10
16
|
return (
|
|
11
17
|
<svg
|
|
@@ -19,6 +25,7 @@ function MicIcon() {
|
|
|
19
25
|
strokeLinecap="round"
|
|
20
26
|
strokeLinejoin="round"
|
|
21
27
|
>
|
|
28
|
+
<title>Microphone on</title>
|
|
22
29
|
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
|
23
30
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
|
24
31
|
<line x1="12" x2="12" y1="19" y2="22" />
|
|
@@ -38,6 +45,7 @@ function MicOffIcon() {
|
|
|
38
45
|
strokeLinecap="round"
|
|
39
46
|
strokeLinejoin="round"
|
|
40
47
|
>
|
|
48
|
+
<title>Microphone off</title>
|
|
41
49
|
<line x1="2" x2="22" y1="2" y2="22" />
|
|
42
50
|
<path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" />
|
|
43
51
|
<path d="M5 10v2a7 7 0 0 0 12 5" />
|
|
@@ -60,6 +68,7 @@ function SendIcon() {
|
|
|
60
68
|
strokeLinecap="round"
|
|
61
69
|
strokeLinejoin="round"
|
|
62
70
|
>
|
|
71
|
+
<title>Send</title>
|
|
63
72
|
<path d="m22 2-7 20-4-9-9-4Z" />
|
|
64
73
|
<path d="M22 2 11 13" />
|
|
65
74
|
</svg>
|
|
@@ -78,6 +87,7 @@ function CloseIcon() {
|
|
|
78
87
|
strokeLinecap="round"
|
|
79
88
|
strokeLinejoin="round"
|
|
80
89
|
>
|
|
90
|
+
<title>Close</title>
|
|
81
91
|
<path d="M18 6 6 18" />
|
|
82
92
|
<path d="m6 6 12 12" />
|
|
83
93
|
</svg>
|
|
@@ -85,40 +95,51 @@ function CloseIcon() {
|
|
|
85
95
|
}
|
|
86
96
|
|
|
87
97
|
interface WidgetPanelProps {
|
|
88
|
-
config: WidgetConfig;
|
|
89
98
|
session: {
|
|
90
99
|
status: SessionStatus;
|
|
91
100
|
roomConnection: { token: string; livekitUrl: string } | null;
|
|
92
101
|
messages: ChatMessage[];
|
|
93
102
|
isVoiceEnabled: boolean;
|
|
94
103
|
isMicActive: boolean;
|
|
104
|
+
isScreenSharing: boolean;
|
|
105
|
+
hasObserver: boolean;
|
|
95
106
|
error: string | null;
|
|
107
|
+
needsIdentityForm: boolean;
|
|
108
|
+
identityCollection: IdentityCollection | null;
|
|
109
|
+
resolvedConfig: ResolvedWidgetConfig | null;
|
|
96
110
|
connect: () => Promise<void>;
|
|
97
111
|
disconnect: () => void;
|
|
98
112
|
addMessage: (
|
|
99
113
|
role: "user" | "agent",
|
|
100
114
|
text: string,
|
|
101
115
|
source: "chat" | "voice",
|
|
116
|
+
senderName?: string,
|
|
117
|
+
senderType?: "human",
|
|
102
118
|
) => void;
|
|
103
119
|
sendMessage: (text: string) => void;
|
|
104
120
|
toggleMic: () => void;
|
|
105
121
|
toggleVoice: () => void;
|
|
122
|
+
toggleScreenShare: () => void;
|
|
106
123
|
setRoom: (room: Room | null) => void;
|
|
124
|
+
setUser: (user: WidgetUser) => void;
|
|
107
125
|
};
|
|
108
126
|
onClose: () => void;
|
|
127
|
+
hidden?: boolean;
|
|
109
128
|
}
|
|
110
129
|
|
|
111
|
-
export function WidgetPanel({
|
|
130
|
+
export function WidgetPanel({ session, onClose, hidden }: WidgetPanelProps) {
|
|
112
131
|
const [inputValue, setInputValue] = useState("");
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
132
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
133
|
+
const rc = session.resolvedConfig;
|
|
134
|
+
const primaryColor = rc?.primaryColor || "#171717";
|
|
135
|
+
const showVoice = rc?.enableVoice !== false;
|
|
117
136
|
|
|
118
137
|
const handleSend = useCallback(() => {
|
|
119
138
|
if (!inputValue.trim()) return;
|
|
120
139
|
session.sendMessage(inputValue);
|
|
121
140
|
setInputValue("");
|
|
141
|
+
const el = textareaRef.current;
|
|
142
|
+
if (el) el.style.height = "auto";
|
|
122
143
|
}, [inputValue, session]);
|
|
123
144
|
|
|
124
145
|
const handleKeyDown = useCallback(
|
|
@@ -131,15 +152,24 @@ export function WidgetPanel({ config, session, onClose }: WidgetPanelProps) {
|
|
|
131
152
|
[handleSend],
|
|
132
153
|
);
|
|
133
154
|
|
|
155
|
+
const handleInput = useCallback(() => {
|
|
156
|
+
const el = textareaRef.current;
|
|
157
|
+
if (!el) return;
|
|
158
|
+
el.style.height = "auto";
|
|
159
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
134
162
|
return (
|
|
135
|
-
<div
|
|
136
|
-
{
|
|
163
|
+
<div
|
|
164
|
+
className={`${styles.panel} ${hidden ? styles.panelHidden : ""}`}
|
|
165
|
+
aria-hidden={hidden}
|
|
166
|
+
>
|
|
137
167
|
<div className={styles.header}>
|
|
138
168
|
<div>
|
|
139
169
|
<div style={{ fontWeight: 500, fontSize: 16 }}>
|
|
140
|
-
{
|
|
170
|
+
{rc?.title || "Hi there"}
|
|
141
171
|
</div>
|
|
142
|
-
{
|
|
172
|
+
{rc?.subtitle && (
|
|
143
173
|
<div
|
|
144
174
|
style={{
|
|
145
175
|
fontSize: 13,
|
|
@@ -147,7 +177,7 @@ export function WidgetPanel({ config, session, onClose }: WidgetPanelProps) {
|
|
|
147
177
|
marginTop: 2,
|
|
148
178
|
}}
|
|
149
179
|
>
|
|
150
|
-
{
|
|
180
|
+
{rc.subtitle}
|
|
151
181
|
</div>
|
|
152
182
|
)}
|
|
153
183
|
</div>
|
|
@@ -155,73 +185,72 @@ export function WidgetPanel({ config, session, onClose }: WidgetPanelProps) {
|
|
|
155
185
|
<CloseIcon />
|
|
156
186
|
</button>
|
|
157
187
|
</div>
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
/>
|
|
165
|
-
|
|
166
|
-
{/* Voice Overlay */}
|
|
167
|
-
{!isChatOnlyAgent && (
|
|
168
|
-
<VoiceOverlay
|
|
169
|
-
isVoiceEnabled={session.isVoiceEnabled}
|
|
170
|
-
isMicActive={session.isMicActive}
|
|
171
|
-
status={session.status}
|
|
172
|
-
onToggleVoice={session.toggleVoice}
|
|
188
|
+
{session.needsIdentityForm && session.identityCollection ? (
|
|
189
|
+
<IdentityForm
|
|
190
|
+
collectEmail={session.identityCollection.collectEmail}
|
|
191
|
+
collectPhone={session.identityCollection.collectPhone}
|
|
192
|
+
primaryColor={primaryColor}
|
|
193
|
+
onSubmit={session.setUser}
|
|
173
194
|
/>
|
|
174
|
-
)}
|
|
175
|
-
|
|
176
|
-
{/* Error */}
|
|
177
|
-
{session.error && <div className={styles.errorText}>{session.error}</div>}
|
|
178
|
-
|
|
179
|
-
{/* Connect button or Input bar */}
|
|
180
|
-
{session.status === "idle" || session.status === "error" ? (
|
|
181
|
-
<button
|
|
182
|
-
type="button"
|
|
183
|
-
className={styles.connectButton}
|
|
184
|
-
style={{ backgroundColor: primaryColor }}
|
|
185
|
-
onClick={session.connect}
|
|
186
|
-
>
|
|
187
|
-
Start conversation
|
|
188
|
-
</button>
|
|
189
|
-
) : session.status === "connecting" ? (
|
|
190
|
-
<button
|
|
191
|
-
type="button"
|
|
192
|
-
className={styles.connectButton}
|
|
193
|
-
style={{ backgroundColor: primaryColor }}
|
|
194
|
-
disabled
|
|
195
|
-
>
|
|
196
|
-
Connecting...
|
|
197
|
-
</button>
|
|
198
195
|
) : (
|
|
199
|
-
|
|
200
|
-
<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
onChange={(e) => setInputValue(e.target.value)}
|
|
205
|
-
onKeyDown={handleKeyDown}
|
|
196
|
+
<>
|
|
197
|
+
<ChatThread
|
|
198
|
+
messages={session.messages}
|
|
199
|
+
primaryColor={primaryColor}
|
|
200
|
+
status={session.status}
|
|
206
201
|
/>
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
202
|
+
{(showVoice || session.hasObserver) && (
|
|
203
|
+
<VoiceOverlay
|
|
204
|
+
isVoiceEnabled={session.isVoiceEnabled}
|
|
205
|
+
isMicActive={session.isMicActive}
|
|
206
|
+
isScreenSharing={session.isScreenSharing}
|
|
207
|
+
status={session.status}
|
|
208
|
+
onToggleVoice={session.toggleVoice}
|
|
209
|
+
onToggleScreenShare={session.toggleScreenShare}
|
|
210
|
+
showScreenShare={session.hasObserver}
|
|
211
|
+
/>
|
|
212
|
+
)}
|
|
213
|
+
{session.error && (
|
|
214
|
+
<div className={styles.errorText}>{session.error}</div>
|
|
215
|
+
)}
|
|
216
|
+
<div className={styles.inputBar}>
|
|
217
|
+
<textarea
|
|
218
|
+
ref={textareaRef}
|
|
219
|
+
className={styles.inputField}
|
|
220
|
+
placeholder={
|
|
221
|
+
session.status === "connecting"
|
|
222
|
+
? "Connecting..."
|
|
223
|
+
: "Type a message..."
|
|
224
|
+
}
|
|
225
|
+
value={inputValue}
|
|
226
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
227
|
+
onInput={handleInput}
|
|
228
|
+
onKeyDown={handleKeyDown}
|
|
229
|
+
disabled={session.status === "connecting"}
|
|
230
|
+
rows={1}
|
|
231
|
+
/>
|
|
216
232
|
<button
|
|
217
233
|
type="button"
|
|
218
|
-
className={
|
|
219
|
-
onClick={
|
|
234
|
+
className={styles.iconButton}
|
|
235
|
+
onClick={handleSend}
|
|
236
|
+
disabled={!inputValue.trim() || session.status === "connecting"}
|
|
220
237
|
>
|
|
221
|
-
|
|
238
|
+
<SendIcon />
|
|
222
239
|
</button>
|
|
223
|
-
|
|
224
|
-
|
|
240
|
+
{session.isVoiceEnabled &&
|
|
241
|
+
showVoice &&
|
|
242
|
+
session.status === "connected" && (
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
className={`${styles.iconButton} ${session.isMicActive ? styles.iconButtonActive : ""}`}
|
|
246
|
+
onClick={session.toggleMic}
|
|
247
|
+
title={session.isMicActive ? "Mute mic" : "Unmute mic"}
|
|
248
|
+
>
|
|
249
|
+
{session.isMicActive ? <MicIcon /> : <MicOffIcon />}
|
|
250
|
+
</button>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
</>
|
|
225
254
|
)}
|
|
226
255
|
</div>
|
|
227
256
|
);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { IdentityForm } from "../IdentityForm";
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
cleanup();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
function renderForm(
|
|
10
|
+
overrides: {
|
|
11
|
+
collectEmail?: "required" | "optional" | "hidden";
|
|
12
|
+
collectPhone?: "required" | "optional" | "hidden";
|
|
13
|
+
onSubmit?: (u: { name?: string; email?: string; phone?: string }) => void;
|
|
14
|
+
} = {},
|
|
15
|
+
) {
|
|
16
|
+
const onSubmit = overrides.onSubmit ?? vi.fn();
|
|
17
|
+
render(
|
|
18
|
+
<IdentityForm
|
|
19
|
+
collectEmail={overrides.collectEmail ?? "hidden"}
|
|
20
|
+
collectPhone={overrides.collectPhone ?? "hidden"}
|
|
21
|
+
primaryColor="#000"
|
|
22
|
+
onSubmit={onSubmit}
|
|
23
|
+
/>,
|
|
24
|
+
);
|
|
25
|
+
return { onSubmit };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("IdentityForm", () => {
|
|
29
|
+
// ── Rendering ──
|
|
30
|
+
|
|
31
|
+
it("always shows a name field", () => {
|
|
32
|
+
renderForm();
|
|
33
|
+
expect(screen.getByPlaceholderText("Your name")).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("shows email field when collectEmail is not hidden", () => {
|
|
37
|
+
renderForm({ collectEmail: "required" });
|
|
38
|
+
expect(screen.getByPlaceholderText("you@example.com")).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("hides email field when collectEmail is hidden", () => {
|
|
42
|
+
renderForm({ collectEmail: "hidden" });
|
|
43
|
+
expect(
|
|
44
|
+
screen.queryByPlaceholderText("you@example.com"),
|
|
45
|
+
).not.toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("shows phone field when collectPhone is not hidden", () => {
|
|
49
|
+
renderForm({ collectPhone: "optional" });
|
|
50
|
+
expect(
|
|
51
|
+
screen.getByPlaceholderText("+1 (555) 123-4567"),
|
|
52
|
+
).toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("hides phone field when collectPhone is hidden", () => {
|
|
56
|
+
renderForm({ collectPhone: "hidden" });
|
|
57
|
+
expect(
|
|
58
|
+
screen.queryByPlaceholderText("+1 (555) 123-4567"),
|
|
59
|
+
).not.toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── Validation ──
|
|
63
|
+
|
|
64
|
+
it("shows error and does not call onSubmit when name is empty", () => {
|
|
65
|
+
const { onSubmit } = renderForm();
|
|
66
|
+
fireEvent.click(screen.getByRole("button", { name: "Start Chat" }));
|
|
67
|
+
expect(screen.getByText("Name is required")).toBeInTheDocument();
|
|
68
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("shows error when required email is missing", () => {
|
|
72
|
+
const { onSubmit } = renderForm({ collectEmail: "required" });
|
|
73
|
+
fireEvent.change(screen.getByPlaceholderText("Your name"), {
|
|
74
|
+
target: { value: "Jane" },
|
|
75
|
+
});
|
|
76
|
+
fireEvent.click(screen.getByRole("button", { name: "Start Chat" }));
|
|
77
|
+
expect(screen.getByText("Email is required")).toBeInTheDocument();
|
|
78
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("shows error for invalid email format", () => {
|
|
82
|
+
const { onSubmit } = renderForm({ collectEmail: "optional" });
|
|
83
|
+
fireEvent.change(screen.getByPlaceholderText("Your name"), {
|
|
84
|
+
target: { value: "Jane" },
|
|
85
|
+
});
|
|
86
|
+
fireEvent.change(screen.getByPlaceholderText("you@example.com"), {
|
|
87
|
+
target: { value: "not-an-email" },
|
|
88
|
+
});
|
|
89
|
+
// Use fireEvent.submit to bypass native <input type="email"> validation in jsdom
|
|
90
|
+
fireEvent.submit(
|
|
91
|
+
screen.getByRole("button", { name: "Start Chat" }).closest("form")!,
|
|
92
|
+
);
|
|
93
|
+
expect(screen.getByText("Invalid email format")).toBeInTheDocument();
|
|
94
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("shows error for invalid phone number (too few digits)", () => {
|
|
98
|
+
const { onSubmit } = renderForm({ collectPhone: "optional" });
|
|
99
|
+
fireEvent.change(screen.getByPlaceholderText("Your name"), {
|
|
100
|
+
target: { value: "Jane" },
|
|
101
|
+
});
|
|
102
|
+
fireEvent.change(screen.getByPlaceholderText("+1 (555) 123-4567"), {
|
|
103
|
+
target: { value: "123" },
|
|
104
|
+
});
|
|
105
|
+
fireEvent.click(screen.getByRole("button", { name: "Start Chat" }));
|
|
106
|
+
expect(
|
|
107
|
+
screen.getByText("Enter a valid phone number (7–15 digits)"),
|
|
108
|
+
).toBeInTheDocument();
|
|
109
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("shows error when both optional and neither provided", () => {
|
|
113
|
+
const { onSubmit } = renderForm({
|
|
114
|
+
collectEmail: "optional",
|
|
115
|
+
collectPhone: "optional",
|
|
116
|
+
});
|
|
117
|
+
fireEvent.change(screen.getByPlaceholderText("Your name"), {
|
|
118
|
+
target: { value: "Jane" },
|
|
119
|
+
});
|
|
120
|
+
fireEvent.click(screen.getByRole("button", { name: "Start Chat" }));
|
|
121
|
+
expect(
|
|
122
|
+
screen.getByText("Please provide either an email or phone number"),
|
|
123
|
+
).toBeInTheDocument();
|
|
124
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("calls onSubmit with valid data", () => {
|
|
128
|
+
const onSubmit = vi.fn();
|
|
129
|
+
renderForm({
|
|
130
|
+
collectEmail: "optional",
|
|
131
|
+
collectPhone: "hidden",
|
|
132
|
+
onSubmit,
|
|
133
|
+
});
|
|
134
|
+
fireEvent.change(screen.getByPlaceholderText("Your name"), {
|
|
135
|
+
target: { value: "Jane" },
|
|
136
|
+
});
|
|
137
|
+
fireEvent.change(screen.getByPlaceholderText("you@example.com"), {
|
|
138
|
+
target: { value: "jane@test.com" },
|
|
139
|
+
});
|
|
140
|
+
fireEvent.click(screen.getByRole("button", { name: "Start Chat" }));
|
|
141
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
142
|
+
name: "Jane",
|
|
143
|
+
email: "jane@test.com",
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cleanup,
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
waitFor,
|
|
7
|
+
} from "@testing-library/react";
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { PolymorphWidget } from "../PolymorphWidget";
|
|
10
|
+
|
|
11
|
+
// Mock LiveKitRoom to avoid real WebSocket connections
|
|
12
|
+
vi.mock("@livekit/components-react", () => ({
|
|
13
|
+
LiveKitRoom: ({ children }: { children: React.ReactNode }) => (
|
|
14
|
+
<div>{children}</div>
|
|
15
|
+
),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("../RoomHandler", () => ({
|
|
19
|
+
RoomHandler: () => null,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// Mock MantineProvider to avoid jsdom getComputedStyle issues
|
|
23
|
+
vi.mock("@mantine/core", async () => {
|
|
24
|
+
const actual = await vi.importActual("@mantine/core");
|
|
25
|
+
return {
|
|
26
|
+
...actual,
|
|
27
|
+
MantineProvider: ({ children }: { children: React.ReactNode }) => (
|
|
28
|
+
<>{children}</>
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const RESOLVE_RESPONSE = {
|
|
34
|
+
id: "cfg-1",
|
|
35
|
+
title: "Widget Title",
|
|
36
|
+
subtitle: "Widget Subtitle",
|
|
37
|
+
primary_color: "#ff0000",
|
|
38
|
+
position: "bottom-right",
|
|
39
|
+
dark_mode: false,
|
|
40
|
+
enable_voice: true,
|
|
41
|
+
greeting: "Welcome!",
|
|
42
|
+
collect_email: "hidden",
|
|
43
|
+
collect_phone: "hidden",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
fetchMock = vi.fn().mockImplementation((url: string) => {
|
|
50
|
+
if (url.includes("/widget-configs/resolve")) {
|
|
51
|
+
return Promise.resolve({
|
|
52
|
+
ok: true,
|
|
53
|
+
json: () => Promise.resolve(RESOLVE_RESPONSE),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return Promise.resolve({
|
|
57
|
+
ok: false,
|
|
58
|
+
status: 404,
|
|
59
|
+
text: () => Promise.resolve(""),
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
63
|
+
sessionStorage.clear();
|
|
64
|
+
localStorage.clear();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
cleanup();
|
|
69
|
+
vi.restoreAllMocks();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("PolymorphWidget", () => {
|
|
73
|
+
it("renders FAB button on mount", () => {
|
|
74
|
+
render(<PolymorphWidget apiBaseUrl="http://test" apiKey="key" />);
|
|
75
|
+
expect(screen.getByTitle("Chat")).toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("clicking FAB opens the panel", async () => {
|
|
79
|
+
render(<PolymorphWidget apiBaseUrl="http://test" apiKey="key" />);
|
|
80
|
+
const fab = screen.getByTitle("Chat").closest("button")!;
|
|
81
|
+
fireEvent.click(fab);
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(
|
|
84
|
+
document.querySelector("[aria-hidden='false']"),
|
|
85
|
+
).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("clicking FAB again closes the panel", async () => {
|
|
90
|
+
render(<PolymorphWidget apiBaseUrl="http://test" apiKey="key" />);
|
|
91
|
+
const fab = screen.getByTitle("Chat").closest("button")!;
|
|
92
|
+
|
|
93
|
+
// Open
|
|
94
|
+
fireEvent.click(fab);
|
|
95
|
+
await waitFor(() => {
|
|
96
|
+
expect(
|
|
97
|
+
document.querySelector("[aria-hidden='false']"),
|
|
98
|
+
).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// The FAB now shows a Close icon — find the FAB button specifically
|
|
102
|
+
// (not the panel header close button) by its CSS class
|
|
103
|
+
const fabButtons = screen
|
|
104
|
+
.getAllByTitle("Close")
|
|
105
|
+
.map((el) => el.closest("button")!);
|
|
106
|
+
const fabBtn = fabButtons.find((btn) => btn.className.includes("fab"))!;
|
|
107
|
+
fireEvent.click(fabBtn);
|
|
108
|
+
await waitFor(() => {
|
|
109
|
+
expect(
|
|
110
|
+
document.querySelector("[aria-hidden='true']"),
|
|
111
|
+
).toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("shows greeting message after config loads", async () => {
|
|
116
|
+
render(<PolymorphWidget apiBaseUrl="http://test" apiKey="key" />);
|
|
117
|
+
await waitFor(() => {
|
|
118
|
+
expect(screen.getByText("Welcome!")).toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("applies correct position class from config", async () => {
|
|
123
|
+
const leftResponse = { ...RESOLVE_RESPONSE, position: "bottom-left" };
|
|
124
|
+
fetchMock.mockImplementation((url: string) => {
|
|
125
|
+
if (url.includes("/widget-configs/resolve")) {
|
|
126
|
+
return Promise.resolve({
|
|
127
|
+
ok: true,
|
|
128
|
+
json: () => Promise.resolve(leftResponse),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return Promise.resolve({
|
|
132
|
+
ok: false,
|
|
133
|
+
status: 404,
|
|
134
|
+
text: () => Promise.resolve(""),
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
render(<PolymorphWidget apiBaseUrl="http://test" apiKey="key" />);
|
|
139
|
+
|
|
140
|
+
await waitFor(() => {
|
|
141
|
+
expect(screen.getByText("Welcome!")).toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const root = document.querySelector(".polymorph-widget")!;
|
|
145
|
+
expect(root.className).toContain("bottomLeft");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("dark mode applies correct color scheme", async () => {
|
|
149
|
+
const darkResponse = { ...RESOLVE_RESPONSE, dark_mode: true };
|
|
150
|
+
fetchMock.mockImplementation((url: string) => {
|
|
151
|
+
if (url.includes("/widget-configs/resolve")) {
|
|
152
|
+
return Promise.resolve({
|
|
153
|
+
ok: true,
|
|
154
|
+
json: () => Promise.resolve(darkResponse),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return Promise.resolve({
|
|
158
|
+
ok: false,
|
|
159
|
+
status: 404,
|
|
160
|
+
text: () => Promise.resolve(""),
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
render(<PolymorphWidget apiBaseUrl="http://test" apiKey="key" />);
|
|
165
|
+
|
|
166
|
+
await waitFor(() => {
|
|
167
|
+
expect(screen.getByText("Welcome!")).toBeInTheDocument();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const root = document.querySelector(".polymorph-widget")!;
|
|
171
|
+
expect((root as HTMLElement).style.colorScheme).toBe("dark");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
const API_URL = process.env.DEMO_RESEARCHER_API_URL;
|
|
4
|
+
const API_KEY = process.env.INTEGRATION_TEST_API_KEY;
|
|
5
|
+
|
|
6
|
+
const shouldRun = API_URL && API_KEY;
|
|
7
|
+
|
|
8
|
+
describe.skipIf(!shouldRun)("SDK integration against demo", () => {
|
|
9
|
+
it("resolves widget config", async () => {
|
|
10
|
+
const res = await fetch(`${API_URL}/widget-configs/resolve`, {
|
|
11
|
+
headers: { Authorization: `Bearer ${API_KEY}` },
|
|
12
|
+
});
|
|
13
|
+
expect(res.status).toBe(200);
|
|
14
|
+
const data = await res.json();
|
|
15
|
+
expect(data).toHaveProperty("id");
|
|
16
|
+
expect(data).toHaveProperty("title");
|
|
17
|
+
expect(data).toHaveProperty("greeting");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("starts a voice room session", async () => {
|
|
21
|
+
const res = await fetch(`${API_URL}/voice-rooms/start`, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
agent_name: "custom-voice-agent",
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
expect(res.status).toBe(200);
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
expect(data).toHaveProperty("token");
|
|
34
|
+
expect(data).toHaveProperty("livekit_url");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("resolved config has all expected fields", async () => {
|
|
38
|
+
const res = await fetch(`${API_URL}/widget-configs/resolve`, {
|
|
39
|
+
headers: { Authorization: `Bearer ${API_KEY}` },
|
|
40
|
+
});
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
const expectedFields = [
|
|
43
|
+
"id",
|
|
44
|
+
"title",
|
|
45
|
+
"subtitle",
|
|
46
|
+
"primary_color",
|
|
47
|
+
"position",
|
|
48
|
+
"dark_mode",
|
|
49
|
+
"enable_voice",
|
|
50
|
+
"greeting",
|
|
51
|
+
"collect_email",
|
|
52
|
+
"collect_phone",
|
|
53
|
+
];
|
|
54
|
+
for (const field of expectedFields) {
|
|
55
|
+
expect(data).toHaveProperty(field);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|