@perspective-ai/sdk 1.0.0-alpha.2

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.
Files changed (45) hide show
  1. package/README.md +333 -0
  2. package/dist/browser.cjs +1939 -0
  3. package/dist/browser.cjs.map +1 -0
  4. package/dist/browser.d.cts +213 -0
  5. package/dist/browser.d.ts +213 -0
  6. package/dist/browser.js +1900 -0
  7. package/dist/browser.js.map +1 -0
  8. package/dist/cdn/perspective.global.js +406 -0
  9. package/dist/cdn/perspective.global.js.map +1 -0
  10. package/dist/constants.cjs +142 -0
  11. package/dist/constants.cjs.map +1 -0
  12. package/dist/constants.d.cts +104 -0
  13. package/dist/constants.d.ts +104 -0
  14. package/dist/constants.js +127 -0
  15. package/dist/constants.js.map +1 -0
  16. package/dist/index.cjs +1596 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +155 -0
  19. package/dist/index.d.ts +155 -0
  20. package/dist/index.js +1579 -0
  21. package/dist/index.js.map +1 -0
  22. package/package.json +83 -0
  23. package/src/browser.test.ts +388 -0
  24. package/src/browser.ts +509 -0
  25. package/src/config.test.ts +81 -0
  26. package/src/config.ts +95 -0
  27. package/src/constants.ts +214 -0
  28. package/src/float.test.ts +332 -0
  29. package/src/float.ts +231 -0
  30. package/src/fullpage.test.ts +224 -0
  31. package/src/fullpage.ts +126 -0
  32. package/src/iframe.test.ts +1037 -0
  33. package/src/iframe.ts +421 -0
  34. package/src/index.ts +61 -0
  35. package/src/loading.ts +90 -0
  36. package/src/popup.test.ts +344 -0
  37. package/src/popup.ts +157 -0
  38. package/src/slider.test.ts +277 -0
  39. package/src/slider.ts +158 -0
  40. package/src/styles.ts +395 -0
  41. package/src/types.ts +148 -0
  42. package/src/utils.test.ts +162 -0
  43. package/src/utils.ts +86 -0
  44. package/src/widget.test.ts +375 -0
  45. package/src/widget.ts +195 -0
@@ -0,0 +1,1037 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2
+ import {
3
+ createIframe,
4
+ setupMessageListener,
5
+ sendMessage,
6
+ registerIframe,
7
+ notifyThemeChange,
8
+ } from "./iframe";
9
+ import { MESSAGE_TYPES, PARAM_KEYS, PARAM_VALUES } from "./constants";
10
+
11
+ describe("createIframe", () => {
12
+ it("creates an iframe element", () => {
13
+ const iframe = createIframe(
14
+ "test-research-id",
15
+ "widget",
16
+ "https://getperspective.ai"
17
+ );
18
+
19
+ expect(iframe).toBeInstanceOf(HTMLIFrameElement);
20
+ expect(iframe.getAttribute("data-perspective")).toBe("true");
21
+ expect(iframe.getAttribute("allow")).toBe("microphone; camera");
22
+ expect(iframe.getAttribute("allowfullscreen")).toBe("true");
23
+ });
24
+
25
+ it("sets correct iframe src with params", () => {
26
+ const iframe = createIframe(
27
+ "test-research-id",
28
+ "widget",
29
+ "https://getperspective.ai",
30
+ { custom: "value" }
31
+ );
32
+
33
+ const src = new URL(iframe.src);
34
+ expect(src.pathname).toBe("/interview/test-research-id");
35
+ expect(src.searchParams.get(PARAM_KEYS.embed)).toBe(PARAM_VALUES.true);
36
+ expect(src.searchParams.get(PARAM_KEYS.embedType)).toBe("widget");
37
+ expect(src.searchParams.get("custom")).toBe("value");
38
+ });
39
+
40
+ it("converts float type to chat for embedType param", () => {
41
+ const iframe = createIframe(
42
+ "test-research-id",
43
+ "float",
44
+ "https://getperspective.ai"
45
+ );
46
+
47
+ const src = new URL(iframe.src);
48
+ expect(src.searchParams.get(PARAM_KEYS.embedType)).toBe("chat");
49
+ });
50
+
51
+ it("includes theme param", () => {
52
+ const iframe = createIframe(
53
+ "test-research-id",
54
+ "widget",
55
+ "https://getperspective.ai",
56
+ undefined,
57
+ undefined,
58
+ "dark"
59
+ );
60
+
61
+ const src = new URL(iframe.src);
62
+ expect(src.searchParams.get(PARAM_KEYS.theme)).toBe("dark");
63
+ });
64
+
65
+ it("includes brand colors", () => {
66
+ const iframe = createIframe(
67
+ "test-research-id",
68
+ "widget",
69
+ "https://getperspective.ai",
70
+ undefined,
71
+ {
72
+ light: { primary: "#ff0000", secondary: "#00ff00" },
73
+ dark: { primary: "#0000ff" },
74
+ }
75
+ );
76
+
77
+ const src = new URL(iframe.src);
78
+ expect(src.searchParams.get("brand.primary")).toBe("#ff0000");
79
+ expect(src.searchParams.get("brand.secondary")).toBe("#00ff00");
80
+ expect(src.searchParams.get("brand.dark.primary")).toBe("#0000ff");
81
+ });
82
+
83
+ it("sets sandbox attribute", () => {
84
+ const iframe = createIframe(
85
+ "test-research-id",
86
+ "widget",
87
+ "https://getperspective.ai"
88
+ );
89
+
90
+ const sandbox = iframe.getAttribute("sandbox");
91
+ expect(sandbox).toContain("allow-scripts");
92
+ expect(sandbox).toContain("allow-same-origin");
93
+ expect(sandbox).toContain("allow-forms");
94
+ expect(sandbox).toContain("allow-popups");
95
+ });
96
+ });
97
+
98
+ describe("setupMessageListener", () => {
99
+ let iframe: HTMLIFrameElement;
100
+ let removeListener: () => void;
101
+ const host = "https://getperspective.ai";
102
+ const researchId = "test-research-id";
103
+
104
+ beforeEach(() => {
105
+ iframe = document.createElement("iframe");
106
+ document.body.appendChild(iframe);
107
+ });
108
+
109
+ afterEach(() => {
110
+ removeListener?.();
111
+ iframe.remove();
112
+ vi.restoreAllMocks();
113
+ });
114
+
115
+ it("returns cleanup function", () => {
116
+ removeListener = setupMessageListener(researchId, {}, iframe, host);
117
+ expect(typeof removeListener).toBe("function");
118
+ });
119
+
120
+ it("ignores messages from wrong origin", () => {
121
+ const onReady = vi.fn();
122
+ removeListener = setupMessageListener(
123
+ researchId,
124
+ { onReady },
125
+ iframe,
126
+ host
127
+ );
128
+
129
+ window.dispatchEvent(
130
+ new MessageEvent("message", {
131
+ data: { type: MESSAGE_TYPES.ready, researchId },
132
+ origin: "https://evil.com",
133
+ source: iframe.contentWindow,
134
+ })
135
+ );
136
+
137
+ expect(onReady).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("ignores messages with wrong researchId", () => {
141
+ const onReady = vi.fn();
142
+ removeListener = setupMessageListener(
143
+ researchId,
144
+ { onReady },
145
+ iframe,
146
+ host
147
+ );
148
+
149
+ window.dispatchEvent(
150
+ new MessageEvent("message", {
151
+ data: { type: MESSAGE_TYPES.ready, researchId: "wrong-id" },
152
+ origin: host,
153
+ source: iframe.contentWindow,
154
+ })
155
+ );
156
+
157
+ expect(onReady).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it("ignores non-perspective messages", () => {
161
+ const onReady = vi.fn();
162
+ removeListener = setupMessageListener(
163
+ researchId,
164
+ { onReady },
165
+ iframe,
166
+ host
167
+ );
168
+
169
+ window.dispatchEvent(
170
+ new MessageEvent("message", {
171
+ data: { type: "other:ready", researchId },
172
+ origin: host,
173
+ source: iframe.contentWindow,
174
+ })
175
+ );
176
+
177
+ expect(onReady).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it("calls onSubmit for submit messages", () => {
181
+ const onSubmit = vi.fn();
182
+ removeListener = setupMessageListener(
183
+ researchId,
184
+ { onSubmit },
185
+ iframe,
186
+ host
187
+ );
188
+
189
+ window.dispatchEvent(
190
+ new MessageEvent("message", {
191
+ data: { type: MESSAGE_TYPES.submit, researchId },
192
+ origin: host,
193
+ source: iframe.contentWindow,
194
+ })
195
+ );
196
+
197
+ expect(onSubmit).toHaveBeenCalledWith({ researchId });
198
+ });
199
+
200
+ it("calls onClose for close messages", () => {
201
+ const onClose = vi.fn();
202
+ removeListener = setupMessageListener(
203
+ researchId,
204
+ { onClose },
205
+ iframe,
206
+ host
207
+ );
208
+
209
+ window.dispatchEvent(
210
+ new MessageEvent("message", {
211
+ data: { type: MESSAGE_TYPES.close, researchId },
212
+ origin: host,
213
+ source: iframe.contentWindow,
214
+ })
215
+ );
216
+
217
+ expect(onClose).toHaveBeenCalled();
218
+ });
219
+
220
+ it("calls onError for error messages", () => {
221
+ const onError = vi.fn();
222
+ removeListener = setupMessageListener(
223
+ researchId,
224
+ { onError },
225
+ iframe,
226
+ host
227
+ );
228
+
229
+ window.dispatchEvent(
230
+ new MessageEvent("message", {
231
+ data: {
232
+ type: MESSAGE_TYPES.error,
233
+ researchId,
234
+ error: "Test error",
235
+ code: "SDK_OUTDATED",
236
+ },
237
+ origin: host,
238
+ source: iframe.contentWindow,
239
+ })
240
+ );
241
+
242
+ expect(onError).toHaveBeenCalled();
243
+ const error = onError.mock.calls[0]?.[0];
244
+ expect(error?.message).toBe("Test error");
245
+ expect(error?.code).toBe("SDK_OUTDATED");
246
+ });
247
+
248
+ it("resizes iframe on resize messages", () => {
249
+ removeListener = setupMessageListener(researchId, {}, iframe, host);
250
+
251
+ window.dispatchEvent(
252
+ new MessageEvent("message", {
253
+ data: { type: MESSAGE_TYPES.resize, researchId, height: 500 },
254
+ origin: host,
255
+ source: iframe.contentWindow,
256
+ })
257
+ );
258
+
259
+ expect(iframe.style.height).toBe("500px");
260
+ });
261
+
262
+ it("skips resize when skipResize option is true", () => {
263
+ iframe.style.height = "300px";
264
+ removeListener = setupMessageListener(researchId, {}, iframe, host, {
265
+ skipResize: true,
266
+ });
267
+
268
+ window.dispatchEvent(
269
+ new MessageEvent("message", {
270
+ data: { type: MESSAGE_TYPES.resize, researchId, height: 500 },
271
+ origin: host,
272
+ source: iframe.contentWindow,
273
+ })
274
+ );
275
+
276
+ expect(iframe.style.height).toBe("300px");
277
+ });
278
+
279
+ it("blocks unsafe redirect URLs", () => {
280
+ const onNavigate = vi.fn();
281
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
282
+ removeListener = setupMessageListener(
283
+ researchId,
284
+ { onNavigate },
285
+ iframe,
286
+ host
287
+ );
288
+
289
+ window.dispatchEvent(
290
+ new MessageEvent("message", {
291
+ data: {
292
+ type: MESSAGE_TYPES.redirect,
293
+ researchId,
294
+ url: "javascript:alert(1)",
295
+ },
296
+ origin: host,
297
+ source: iframe.contentWindow,
298
+ })
299
+ );
300
+
301
+ expect(onNavigate).not.toHaveBeenCalled();
302
+ expect(warnSpy).toHaveBeenCalled();
303
+ });
304
+
305
+ it("allows https redirect URLs", () => {
306
+ const onNavigate = vi.fn();
307
+ removeListener = setupMessageListener(
308
+ researchId,
309
+ { onNavigate },
310
+ iframe,
311
+ host
312
+ );
313
+
314
+ window.dispatchEvent(
315
+ new MessageEvent("message", {
316
+ data: {
317
+ type: MESSAGE_TYPES.redirect,
318
+ researchId,
319
+ url: "https://example.com/thank-you",
320
+ },
321
+ origin: host,
322
+ source: iframe.contentWindow,
323
+ })
324
+ );
325
+
326
+ expect(onNavigate).toHaveBeenCalledWith("https://example.com/thank-you");
327
+ });
328
+
329
+ it("allows localhost redirect URLs", () => {
330
+ const onNavigate = vi.fn();
331
+ removeListener = setupMessageListener(
332
+ researchId,
333
+ { onNavigate },
334
+ iframe,
335
+ host
336
+ );
337
+
338
+ window.dispatchEvent(
339
+ new MessageEvent("message", {
340
+ data: {
341
+ type: MESSAGE_TYPES.redirect,
342
+ researchId,
343
+ url: "http://localhost:3000/thank-you",
344
+ },
345
+ origin: host,
346
+ source: iframe.contentWindow,
347
+ })
348
+ );
349
+
350
+ expect(onNavigate).toHaveBeenCalledWith("http://localhost:3000/thank-you");
351
+ });
352
+ });
353
+
354
+ describe("sendMessage", () => {
355
+ it("handles iframe without contentWindow gracefully", () => {
356
+ const iframe = document.createElement("iframe");
357
+ // Not appended to document, contentWindow might be null
358
+
359
+ expect(() =>
360
+ sendMessage(iframe, "https://getperspective.ai", {
361
+ type: "test",
362
+ })
363
+ ).not.toThrow();
364
+ });
365
+ });
366
+
367
+ describe("registerIframe", () => {
368
+ it("returns cleanup function", () => {
369
+ const iframe = document.createElement("iframe");
370
+ const unregister = registerIframe(iframe, "https://getperspective.ai");
371
+ expect(typeof unregister).toBe("function");
372
+ unregister();
373
+ });
374
+ });
375
+
376
+ describe("notifyThemeChange", () => {
377
+ it("does not throw when called", () => {
378
+ expect(() => notifyThemeChange()).not.toThrow();
379
+ });
380
+ });
381
+
382
+ describe("redirect URL security (isAllowedRedirectUrl)", () => {
383
+ let iframe: HTMLIFrameElement;
384
+ let removeListener: () => void;
385
+ const host = "https://getperspective.ai";
386
+ const researchId = "test-research-id";
387
+
388
+ beforeEach(() => {
389
+ iframe = document.createElement("iframe");
390
+ document.body.appendChild(iframe);
391
+ });
392
+
393
+ afterEach(() => {
394
+ removeListener?.();
395
+ iframe.remove();
396
+ vi.restoreAllMocks();
397
+ });
398
+
399
+ const dispatchRedirect = (url: string) => {
400
+ window.dispatchEvent(
401
+ new MessageEvent("message", {
402
+ data: { type: MESSAGE_TYPES.redirect, researchId, url },
403
+ origin: host,
404
+ source: iframe.contentWindow,
405
+ })
406
+ );
407
+ };
408
+
409
+ describe("allowed URLs", () => {
410
+ const allowedUrls = [
411
+ ["https://example.com", "basic https"],
412
+ ["https://example.com/path", "https with path"],
413
+ ["https://example.com/path?query=1", "https with query"],
414
+ ["https://example.com/path#hash", "https with hash"],
415
+ ["https://example.com:443/path", "https with port 443"],
416
+ ["https://example.com:8443/path", "https with custom port"],
417
+ ["https://sub.domain.example.com", "https subdomain"],
418
+ ["https://a.b.c.d.example.com/deep/path", "https deep subdomain"],
419
+ ["http://localhost", "localhost without port"],
420
+ ["http://localhost/", "localhost with trailing slash"],
421
+ ["http://localhost:3000", "localhost with port"],
422
+ ["http://localhost:3000/path", "localhost with port and path"],
423
+ ["http://localhost:8080/api/callback", "localhost different port"],
424
+ ["http://127.0.0.1", "127.0.0.1 IP"],
425
+ ["http://127.0.0.1:3000", "127.0.0.1 with port"],
426
+ ["http://127.0.0.1:3000/path?q=1", "127.0.0.1 with path and query"],
427
+ ["https://localhost", "https localhost"],
428
+ ["https://localhost:3000", "https localhost with port"],
429
+ ["https://127.0.0.1:8443", "https 127.0.0.1 with port"],
430
+ ];
431
+
432
+ it.each(allowedUrls)("allows %s (%s)", (url, _description) => {
433
+ const onNavigate = vi.fn();
434
+ removeListener = setupMessageListener(
435
+ researchId,
436
+ { onNavigate },
437
+ iframe,
438
+ host
439
+ );
440
+
441
+ dispatchRedirect(url);
442
+
443
+ expect(onNavigate).toHaveBeenCalledWith(url);
444
+ });
445
+ });
446
+
447
+ describe("blocked URLs - dangerous protocols", () => {
448
+ const blockedProtocols = [
449
+ ["javascript:alert(1)", "javascript protocol"],
450
+ ["javascript:void(0)", "javascript void"],
451
+ ["javascript://comment%0aalert(1)", "javascript with comment"],
452
+ ["data:text/html,<script>alert(1)</script>", "data protocol html"],
453
+ [
454
+ "data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
455
+ "data base64",
456
+ ],
457
+ ["vbscript:msgbox(1)", "vbscript protocol"],
458
+ ["file:///etc/passwd", "file protocol"],
459
+ ["file://localhost/etc/passwd", "file with localhost"],
460
+ ["blob:https://evil.com/guid", "blob protocol"],
461
+ ["about:blank", "about protocol"],
462
+ ["ws://evil.com/socket", "websocket protocol"],
463
+ ["wss://evil.com/socket", "secure websocket protocol"],
464
+ ["ftp://evil.com/file", "ftp protocol"],
465
+ ["tel:+1234567890", "tel protocol"],
466
+ ["mailto:evil@example.com", "mailto protocol"],
467
+ ];
468
+
469
+ it.each(blockedProtocols)("blocks %s (%s)", (url, _description) => {
470
+ const onNavigate = vi.fn();
471
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
472
+ removeListener = setupMessageListener(
473
+ researchId,
474
+ { onNavigate },
475
+ iframe,
476
+ host
477
+ );
478
+
479
+ dispatchRedirect(url);
480
+
481
+ expect(onNavigate).not.toHaveBeenCalled();
482
+ expect(warnSpy).toHaveBeenCalledWith(
483
+ expect.stringContaining("Blocked unsafe redirect URL"),
484
+ url
485
+ );
486
+ });
487
+ });
488
+
489
+ describe("blocked URLs - HTTP on non-localhost", () => {
490
+ const blockedHttpUrls = [
491
+ ["http://example.com", "plain http external"],
492
+ ["http://evil.com/phishing", "http phishing"],
493
+ ["http://192.168.1.1", "http private IP"],
494
+ ["http://10.0.0.1", "http internal IP"],
495
+ ["http://169.254.169.254", "http AWS metadata IP"],
496
+ ["http://[::1]", "http IPv6 localhost"],
497
+ ["http://0.0.0.0", "http 0.0.0.0"],
498
+ ["http://localhos", "http typosquat localhost (missing t)"],
499
+ ["http://localhost.evil.com", "http localhost subdomain attack"],
500
+ ["http://127.0.0.2", "http similar IP"],
501
+ ];
502
+
503
+ it.each(blockedHttpUrls)("blocks %s (%s)", (url, _description) => {
504
+ const onNavigate = vi.fn();
505
+ vi.spyOn(console, "warn").mockImplementation(() => {});
506
+ removeListener = setupMessageListener(
507
+ researchId,
508
+ { onNavigate },
509
+ iframe,
510
+ host
511
+ );
512
+
513
+ dispatchRedirect(url);
514
+
515
+ expect(onNavigate).not.toHaveBeenCalled();
516
+ });
517
+ });
518
+
519
+ describe("blocked URLs - malformed and edge cases", () => {
520
+ const blockedUrls = [
521
+ ["", "empty string"],
522
+ ["//evil.com", "protocol-relative URL to external host"],
523
+ ];
524
+
525
+ it.each(blockedUrls)("blocks %s (%s)", (url, _description) => {
526
+ const onNavigate = vi.fn();
527
+ vi.spyOn(console, "warn").mockImplementation(() => {});
528
+ removeListener = setupMessageListener(
529
+ researchId,
530
+ { onNavigate },
531
+ iframe,
532
+ host
533
+ );
534
+
535
+ dispatchRedirect(url);
536
+
537
+ expect(onNavigate).not.toHaveBeenCalled();
538
+ });
539
+ });
540
+
541
+ describe("allowed relative URLs", () => {
542
+ const allowedRelativeUrls = [
543
+ ["/path/only", "path only (relative)"],
544
+ ["?query=only", "query only"],
545
+ ["#hash-only", "hash only"],
546
+ ];
547
+
548
+ it.each(allowedRelativeUrls)(
549
+ "allows relative URL %s (%s) - resolved against origin",
550
+ (url, _description) => {
551
+ const onNavigate = vi.fn();
552
+ removeListener = setupMessageListener(
553
+ researchId,
554
+ { onNavigate },
555
+ iframe,
556
+ host
557
+ );
558
+
559
+ dispatchRedirect(url);
560
+
561
+ expect(onNavigate).toHaveBeenCalledWith(url);
562
+ }
563
+ );
564
+ });
565
+
566
+ describe("blocked URLs - encoding and bypass attempts", () => {
567
+ const blockedBypassAttempts = [
568
+ ["java\tscript:alert(1)", "javascript with tab"],
569
+ ["java\nscript:alert(1)", "javascript with newline"],
570
+ ["java\rscript:alert(1)", "javascript with carriage return"],
571
+ [" javascript:alert(1)", "javascript with leading space"],
572
+ ["JAVASCRIPT:alert(1)", "javascript uppercase"],
573
+ ["JaVaScRiPt:alert(1)", "javascript mixed case"],
574
+ ["https://evil.com%00.good.com", "null byte in hostname"],
575
+ ["https://good.com%40evil.com", "encoded @ in hostname"],
576
+ ];
577
+
578
+ it.each(blockedBypassAttempts)("blocks %s (%s)", (url, _description) => {
579
+ const onNavigate = vi.fn();
580
+ vi.spyOn(console, "warn").mockImplementation(() => {});
581
+ removeListener = setupMessageListener(
582
+ researchId,
583
+ { onNavigate },
584
+ iframe,
585
+ host
586
+ );
587
+
588
+ dispatchRedirect(url);
589
+
590
+ expect(onNavigate).not.toHaveBeenCalled();
591
+ });
592
+ });
593
+
594
+ describe("allowed HTTPS URLs with suspicious-looking patterns", () => {
595
+ it("allows userinfo credential pattern - SECURITY: navigates to evil.com not good.com", () => {
596
+ const onNavigate = vi.fn();
597
+ removeListener = setupMessageListener(
598
+ researchId,
599
+ { onNavigate },
600
+ iframe,
601
+ host
602
+ );
603
+
604
+ dispatchRedirect("https://good.com@evil.com");
605
+
606
+ expect(onNavigate).toHaveBeenCalledWith("https://good.com@evil.com");
607
+ });
608
+ });
609
+
610
+ describe("null and undefined handling", () => {
611
+ it("handles null url gracefully", () => {
612
+ const onNavigate = vi.fn();
613
+ vi.spyOn(console, "warn").mockImplementation(() => {});
614
+ removeListener = setupMessageListener(
615
+ researchId,
616
+ { onNavigate },
617
+ iframe,
618
+ host
619
+ );
620
+
621
+ window.dispatchEvent(
622
+ new MessageEvent("message", {
623
+ data: { type: MESSAGE_TYPES.redirect, researchId, url: null },
624
+ origin: host,
625
+ source: iframe.contentWindow,
626
+ })
627
+ );
628
+
629
+ expect(onNavigate).not.toHaveBeenCalled();
630
+ });
631
+
632
+ it("handles undefined url gracefully", () => {
633
+ const onNavigate = vi.fn();
634
+ vi.spyOn(console, "warn").mockImplementation(() => {});
635
+ removeListener = setupMessageListener(
636
+ researchId,
637
+ { onNavigate },
638
+ iframe,
639
+ host
640
+ );
641
+
642
+ window.dispatchEvent(
643
+ new MessageEvent("message", {
644
+ data: { type: MESSAGE_TYPES.redirect, researchId, url: undefined },
645
+ origin: host,
646
+ source: iframe.contentWindow,
647
+ })
648
+ );
649
+
650
+ expect(onNavigate).not.toHaveBeenCalled();
651
+ });
652
+
653
+ it("handles non-string url gracefully", () => {
654
+ const onNavigate = vi.fn();
655
+ vi.spyOn(console, "warn").mockImplementation(() => {});
656
+ removeListener = setupMessageListener(
657
+ researchId,
658
+ { onNavigate },
659
+ iframe,
660
+ host
661
+ );
662
+
663
+ window.dispatchEvent(
664
+ new MessageEvent("message", {
665
+ data: {
666
+ type: MESSAGE_TYPES.redirect,
667
+ researchId,
668
+ url: { evil: true },
669
+ },
670
+ origin: host,
671
+ source: iframe.contentWindow,
672
+ })
673
+ );
674
+
675
+ expect(onNavigate).not.toHaveBeenCalled();
676
+ });
677
+ });
678
+
679
+ describe("fallback navigation behavior", () => {
680
+ it("navigates via window.location.href when onNavigate not provided", () => {
681
+ const originalHref = window.location.href;
682
+ const mockLocation = { href: originalHref };
683
+
684
+ Object.defineProperty(window, "location", {
685
+ value: mockLocation,
686
+ writable: true,
687
+ configurable: true,
688
+ });
689
+
690
+ removeListener = setupMessageListener(researchId, {}, iframe, host);
691
+
692
+ dispatchRedirect("https://example.com/redirect");
693
+
694
+ expect(mockLocation.href).toBe("https://example.com/redirect");
695
+
696
+ Object.defineProperty(window, "location", {
697
+ value: { href: originalHref },
698
+ writable: true,
699
+ configurable: true,
700
+ });
701
+ });
702
+ });
703
+ });
704
+
705
+ describe("postMessage security", () => {
706
+ let iframe: HTMLIFrameElement;
707
+ let removeListener: () => void;
708
+ const host = "https://getperspective.ai";
709
+ const researchId = "test-research-id";
710
+
711
+ beforeEach(() => {
712
+ iframe = document.createElement("iframe");
713
+ document.body.appendChild(iframe);
714
+ });
715
+
716
+ afterEach(() => {
717
+ removeListener?.();
718
+ iframe.remove();
719
+ vi.restoreAllMocks();
720
+ });
721
+
722
+ describe("source validation", () => {
723
+ it("ignores messages from different iframe", () => {
724
+ const onReady = vi.fn();
725
+ const otherIframe = document.createElement("iframe");
726
+ document.body.appendChild(otherIframe);
727
+
728
+ removeListener = setupMessageListener(
729
+ researchId,
730
+ { onReady },
731
+ iframe,
732
+ host
733
+ );
734
+
735
+ window.dispatchEvent(
736
+ new MessageEvent("message", {
737
+ data: { type: MESSAGE_TYPES.ready, researchId },
738
+ origin: host,
739
+ source: otherIframe.contentWindow,
740
+ })
741
+ );
742
+
743
+ expect(onReady).not.toHaveBeenCalled();
744
+ otherIframe.remove();
745
+ });
746
+
747
+ it("ignores messages with null source", () => {
748
+ const onReady = vi.fn();
749
+ removeListener = setupMessageListener(
750
+ researchId,
751
+ { onReady },
752
+ iframe,
753
+ host
754
+ );
755
+
756
+ window.dispatchEvent(
757
+ new MessageEvent("message", {
758
+ data: { type: MESSAGE_TYPES.ready, researchId },
759
+ origin: host,
760
+ source: null,
761
+ })
762
+ );
763
+
764
+ expect(onReady).not.toHaveBeenCalled();
765
+ });
766
+
767
+ it("ignores messages from window itself", () => {
768
+ const onReady = vi.fn();
769
+ removeListener = setupMessageListener(
770
+ researchId,
771
+ { onReady },
772
+ iframe,
773
+ host
774
+ );
775
+
776
+ window.dispatchEvent(
777
+ new MessageEvent("message", {
778
+ data: { type: MESSAGE_TYPES.ready, researchId },
779
+ origin: host,
780
+ source: window,
781
+ })
782
+ );
783
+
784
+ expect(onReady).not.toHaveBeenCalled();
785
+ });
786
+ });
787
+
788
+ describe("origin validation", () => {
789
+ it("ignores messages from similar but different origins", () => {
790
+ const onReady = vi.fn();
791
+ removeListener = setupMessageListener(
792
+ researchId,
793
+ { onReady },
794
+ iframe,
795
+ host
796
+ );
797
+
798
+ const similarOrigins = [
799
+ "https://getperspective.ai.evil.com",
800
+ "https://evil.getperspective.ai",
801
+ "https://getperspective.ai:8443",
802
+ "http://getperspective.ai",
803
+ "https://getperspective.ai/",
804
+ "https://GETPERSPECTIVE.AI",
805
+ ];
806
+
807
+ for (const origin of similarOrigins) {
808
+ window.dispatchEvent(
809
+ new MessageEvent("message", {
810
+ data: { type: MESSAGE_TYPES.ready, researchId },
811
+ origin,
812
+ source: iframe.contentWindow,
813
+ })
814
+ );
815
+ }
816
+
817
+ expect(onReady).not.toHaveBeenCalled();
818
+ });
819
+ });
820
+
821
+ describe("message type validation", () => {
822
+ it("ignores messages without type", () => {
823
+ const onReady = vi.fn();
824
+ removeListener = setupMessageListener(
825
+ researchId,
826
+ { onReady },
827
+ iframe,
828
+ host
829
+ );
830
+
831
+ window.dispatchEvent(
832
+ new MessageEvent("message", {
833
+ data: { researchId },
834
+ origin: host,
835
+ source: iframe.contentWindow,
836
+ })
837
+ );
838
+
839
+ expect(onReady).not.toHaveBeenCalled();
840
+ });
841
+
842
+ it("ignores messages with non-string type gracefully", () => {
843
+ const onReady = vi.fn();
844
+ removeListener = setupMessageListener(
845
+ researchId,
846
+ { onReady },
847
+ iframe,
848
+ host
849
+ );
850
+
851
+ expect(() => {
852
+ window.dispatchEvent(
853
+ new MessageEvent("message", {
854
+ data: { type: 123, researchId },
855
+ origin: host,
856
+ source: iframe.contentWindow,
857
+ })
858
+ );
859
+ }).not.toThrow();
860
+
861
+ expect(onReady).not.toHaveBeenCalled();
862
+ });
863
+
864
+ it("ignores messages with null data", () => {
865
+ const onReady = vi.fn();
866
+ removeListener = setupMessageListener(
867
+ researchId,
868
+ { onReady },
869
+ iframe,
870
+ host
871
+ );
872
+
873
+ window.dispatchEvent(
874
+ new MessageEvent("message", {
875
+ data: null,
876
+ origin: host,
877
+ source: iframe.contentWindow,
878
+ })
879
+ );
880
+
881
+ expect(onReady).not.toHaveBeenCalled();
882
+ });
883
+
884
+ it("ignores messages with undefined data", () => {
885
+ const onReady = vi.fn();
886
+ removeListener = setupMessageListener(
887
+ researchId,
888
+ { onReady },
889
+ iframe,
890
+ host
891
+ );
892
+
893
+ window.dispatchEvent(
894
+ new MessageEvent("message", {
895
+ data: undefined,
896
+ origin: host,
897
+ source: iframe.contentWindow,
898
+ })
899
+ );
900
+
901
+ expect(onReady).not.toHaveBeenCalled();
902
+ });
903
+ });
904
+
905
+ describe("callback safety", () => {
906
+ it("handles missing onSubmit callback gracefully", () => {
907
+ removeListener = setupMessageListener(researchId, {}, iframe, host);
908
+
909
+ expect(() => {
910
+ window.dispatchEvent(
911
+ new MessageEvent("message", {
912
+ data: { type: MESSAGE_TYPES.submit, researchId },
913
+ origin: host,
914
+ source: iframe.contentWindow,
915
+ })
916
+ );
917
+ }).not.toThrow();
918
+ });
919
+
920
+ it("handles missing onClose callback gracefully", () => {
921
+ removeListener = setupMessageListener(researchId, {}, iframe, host);
922
+
923
+ expect(() => {
924
+ window.dispatchEvent(
925
+ new MessageEvent("message", {
926
+ data: { type: MESSAGE_TYPES.close, researchId },
927
+ origin: host,
928
+ source: iframe.contentWindow,
929
+ })
930
+ );
931
+ }).not.toThrow();
932
+ });
933
+
934
+ it("handles missing onError callback gracefully", () => {
935
+ removeListener = setupMessageListener(researchId, {}, iframe, host);
936
+
937
+ expect(() => {
938
+ window.dispatchEvent(
939
+ new MessageEvent("message", {
940
+ data: { type: MESSAGE_TYPES.error, researchId, error: "test" },
941
+ origin: host,
942
+ source: iframe.contentWindow,
943
+ })
944
+ );
945
+ }).not.toThrow();
946
+ });
947
+
948
+ it("error callback receives properly structured error object", () => {
949
+ const onError = vi.fn();
950
+ removeListener = setupMessageListener(
951
+ researchId,
952
+ { onError },
953
+ iframe,
954
+ host
955
+ );
956
+
957
+ window.dispatchEvent(
958
+ new MessageEvent("message", {
959
+ data: {
960
+ type: MESSAGE_TYPES.error,
961
+ researchId,
962
+ error: "Something went wrong",
963
+ code: "INVALID_RESEARCH",
964
+ },
965
+ origin: host,
966
+ source: iframe.contentWindow,
967
+ })
968
+ );
969
+
970
+ expect(onError).toHaveBeenCalledTimes(1);
971
+ const error = onError.mock.calls[0]![0] as Error & { code?: string };
972
+ expect(error).toBeInstanceOf(Error);
973
+ expect(error.message).toBe("Something went wrong");
974
+ expect(error.code).toBe("INVALID_RESEARCH");
975
+ });
976
+
977
+ it("error defaults to UNKNOWN code when not provided", () => {
978
+ const onError = vi.fn();
979
+ removeListener = setupMessageListener(
980
+ researchId,
981
+ { onError },
982
+ iframe,
983
+ host
984
+ );
985
+
986
+ window.dispatchEvent(
987
+ new MessageEvent("message", {
988
+ data: {
989
+ type: MESSAGE_TYPES.error,
990
+ researchId,
991
+ error: "Unknown error",
992
+ },
993
+ origin: host,
994
+ source: iframe.contentWindow,
995
+ })
996
+ );
997
+
998
+ expect(onError).toHaveBeenCalledTimes(1);
999
+ const error = onError.mock.calls[0]![0] as Error & { code?: string };
1000
+ expect(error.code).toBe("UNKNOWN");
1001
+ });
1002
+ });
1003
+
1004
+ describe("cleanup behavior", () => {
1005
+ it("stops receiving messages after cleanup", () => {
1006
+ const onReady = vi.fn();
1007
+ removeListener = setupMessageListener(
1008
+ researchId,
1009
+ { onReady },
1010
+ iframe,
1011
+ host
1012
+ );
1013
+
1014
+ removeListener();
1015
+
1016
+ window.dispatchEvent(
1017
+ new MessageEvent("message", {
1018
+ data: { type: MESSAGE_TYPES.ready, researchId },
1019
+ origin: host,
1020
+ source: iframe.contentWindow,
1021
+ })
1022
+ );
1023
+
1024
+ expect(onReady).not.toHaveBeenCalled();
1025
+ });
1026
+
1027
+ it("cleanup can be called multiple times safely", () => {
1028
+ removeListener = setupMessageListener(researchId, {}, iframe, host);
1029
+
1030
+ expect(() => {
1031
+ removeListener();
1032
+ removeListener();
1033
+ removeListener();
1034
+ }).not.toThrow();
1035
+ });
1036
+ });
1037
+ });