polymorph-sdk 0.2.3 → 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 +46 -22
- package/dist/index.js +6276 -5929
- package/package.json +1 -1
- package/src/ChatThread.tsx +15 -2
- package/src/IdentityForm.tsx +135 -0
- package/src/PolymorphWidget.tsx +44 -18
- package/src/RoomHandler.tsx +22 -2
- package/src/VoiceOverlay.tsx +60 -11
- package/src/WidgetPanel.tsx +86 -60
- 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 +440 -41
- package/src/types.ts +28 -18
- package/src/usePolymorphSession.ts +338 -78
|
@@ -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
|
+
});
|