@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.
- package/README.md +333 -0
- package/dist/browser.cjs +1939 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +213 -0
- package/dist/browser.d.ts +213 -0
- package/dist/browser.js +1900 -0
- package/dist/browser.js.map +1 -0
- package/dist/cdn/perspective.global.js +406 -0
- package/dist/cdn/perspective.global.js.map +1 -0
- package/dist/constants.cjs +142 -0
- package/dist/constants.cjs.map +1 -0
- package/dist/constants.d.cts +104 -0
- package/dist/constants.d.ts +104 -0
- package/dist/constants.js +127 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.cjs +1596 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +155 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +1579 -0
- package/dist/index.js.map +1 -0
- package/package.json +83 -0
- package/src/browser.test.ts +388 -0
- package/src/browser.ts +509 -0
- package/src/config.test.ts +81 -0
- package/src/config.ts +95 -0
- package/src/constants.ts +214 -0
- package/src/float.test.ts +332 -0
- package/src/float.ts +231 -0
- package/src/fullpage.test.ts +224 -0
- package/src/fullpage.ts +126 -0
- package/src/iframe.test.ts +1037 -0
- package/src/iframe.ts +421 -0
- package/src/index.ts +61 -0
- package/src/loading.ts +90 -0
- package/src/popup.test.ts +344 -0
- package/src/popup.ts +157 -0
- package/src/slider.test.ts +277 -0
- package/src/slider.ts +158 -0
- package/src/styles.ts +395 -0
- package/src/types.ts +148 -0
- package/src/utils.test.ts +162 -0
- package/src/utils.ts +86 -0
- package/src/widget.test.ts +375 -0
- 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
|
+
});
|