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.
@@ -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 { ChatMessage, SessionStatus, WidgetConfig } from "./types";
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({ config, session, onClose }: WidgetPanelProps) {
130
+ export function WidgetPanel({ session, onClose, hidden }: WidgetPanelProps) {
112
131
  const [inputValue, setInputValue] = useState("");
113
- const primaryColor = config.branding?.primaryColor || "#171717";
114
- const isChatOnlyAgent = (config.agentName || "")
115
- .toLowerCase()
116
- .includes("chat-agent");
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 className={styles.panel}>
136
- {/* Header */}
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
- {config.branding?.title || "Hi there"}
170
+ {rc?.title || "Hi there"}
141
171
  </div>
142
- {config.branding?.subtitle && (
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
- {config.branding.subtitle}
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
- {/* Chat Thread */}
160
- <ChatThread
161
- messages={session.messages}
162
- primaryColor={primaryColor}
163
- status={session.status}
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
- <div className={styles.inputBar}>
200
- <input
201
- className={styles.inputField}
202
- placeholder="Type a message..."
203
- value={inputValue}
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
- <button
208
- type="button"
209
- className={styles.iconButton}
210
- onClick={handleSend}
211
- disabled={!inputValue.trim()}
212
- >
213
- <SendIcon />
214
- </button>
215
- {session.isVoiceEnabled && !isChatOnlyAgent && (
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={`${styles.iconButton} ${session.isMicActive ? styles.iconButtonActive : ""}`}
219
- onClick={session.toggleMic}
234
+ className={styles.iconButton}
235
+ onClick={handleSend}
236
+ disabled={!inputValue.trim() || session.status === "connecting"}
220
237
  >
221
- {session.isMicActive ? <MicIcon /> : <MicOffIcon />}
238
+ <SendIcon />
222
239
  </button>
223
- )}
224
- </div>
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
+ });