@squasher-ai/browser 0.1.0 → 0.2.0
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/LICENSE +21 -21
- package/dist/__tests__/client/harness.d.ts +18 -0
- package/dist/__tests__/client/harness.d.ts.map +1 -0
- package/dist/__tests__/client/harness.js +106 -0
- package/dist/__tests__/replay/coverage-harness.d.ts +29 -0
- package/dist/__tests__/replay/coverage-harness.d.ts.map +1 -0
- package/dist/__tests__/replay/coverage-harness.js +123 -0
- package/dist/__tests__/replay/test-helpers.d.ts +13 -0
- package/dist/__tests__/replay/test-helpers.d.ts.map +1 -0
- package/dist/__tests__/replay/test-helpers.js +9 -0
- package/dist/autocapture.d.ts +14 -18
- package/dist/autocapture.d.ts.map +1 -1
- package/dist/autocapture.js +77 -95
- package/dist/client.d.ts +12 -19
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +56 -72
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +26 -0
- package/dist/console-capture.d.ts +8 -0
- package/dist/console-capture.d.ts.map +1 -0
- package/dist/console-capture.js +79 -0
- package/dist/errors.d.ts +2 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +8 -5
- package/dist/index.d.ts +5 -38
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -82
- package/dist/next.d.ts +40 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +67 -0
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +12 -10
- package/dist/replay-network.d.ts +21 -0
- package/dist/replay-network.d.ts.map +1 -0
- package/dist/replay-network.js +193 -0
- package/dist/replay-privacy.d.ts +2 -2
- package/dist/replay-privacy.d.ts.map +1 -1
- package/dist/replay-privacy.js +47 -17
- package/dist/replay-retry.d.ts +41 -0
- package/dist/replay-retry.d.ts.map +1 -0
- package/dist/replay-retry.js +193 -0
- package/dist/replay.d.ts +18 -5
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +131 -71
- package/dist/runtime/client-singleton.d.ts +25 -0
- package/dist/runtime/client-singleton.d.ts.map +1 -0
- package/dist/runtime/client-singleton.js +66 -0
- package/dist/runtime/client.d.ts +22 -0
- package/dist/runtime/client.d.ts.map +1 -0
- package/dist/runtime/client.js +12 -0
- package/dist/runtime/replay.d.ts +13 -0
- package/dist/runtime/replay.d.ts.map +1 -0
- package/dist/runtime/replay.js +15 -0
- package/dist/session.d.ts +0 -10
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +20 -21
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +13 -10
- package/dist/trace-context.d.ts +9 -0
- package/dist/trace-context.d.ts.map +1 -0
- package/dist/trace-context.js +13 -0
- package/dist/transport.d.ts +11 -19
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +86 -66
- package/dist/types.d.ts +37 -135
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -7
- package/dist/vitals.d.ts +55 -2
- package/dist/vitals.d.ts.map +1 -1
- package/dist/vitals.js +115 -16
- package/package.json +17 -3
- package/dist/__tests__/client.test.d.ts +0 -2
- package/dist/__tests__/client.test.d.ts.map +0 -1
- package/dist/__tests__/client.test.js +0 -103
- package/dist/__tests__/errors.test.d.ts +0 -2
- package/dist/__tests__/errors.test.d.ts.map +0 -1
- package/dist/__tests__/errors.test.js +0 -80
- package/dist/__tests__/replay-privacy.test.d.ts +0 -2
- package/dist/__tests__/replay-privacy.test.d.ts.map +0 -1
- package/dist/__tests__/replay-privacy.test.js +0 -124
- package/dist/__tests__/replay-startup.test.d.ts +0 -2
- package/dist/__tests__/replay-startup.test.d.ts.map +0 -1
- package/dist/__tests__/replay-startup.test.js +0 -99
- package/dist/__tests__/replay.test.d.ts +0 -2
- package/dist/__tests__/replay.test.d.ts.map +0 -1
- package/dist/__tests__/replay.test.js +0 -217
- package/dist/__tests__/session.test.d.ts +0 -2
- package/dist/__tests__/session.test.d.ts.map +0 -1
- package/dist/__tests__/session.test.js +0 -46
- package/dist/__tests__/transport.test.d.ts +0 -2
- package/dist/__tests__/transport.test.d.ts.map +0 -1
- package/dist/__tests__/transport.test.js +0 -101
- package/dist/__tests__/types.test.d.ts +0 -2
- package/dist/__tests__/types.test.d.ts.map +0 -1
- package/dist/__tests__/types.test.js +0 -139
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { BrowserClient } from "../client";
|
|
3
|
-
import { resetSessionId } from "../session";
|
|
4
|
-
describe("BrowserClient", () => {
|
|
5
|
-
beforeEach(() => {
|
|
6
|
-
resetSessionId();
|
|
7
|
-
const storage = {};
|
|
8
|
-
vi.restoreAllMocks();
|
|
9
|
-
vi.stubGlobal("crypto", {
|
|
10
|
-
randomUUID: vi.fn().mockReturnValue("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"),
|
|
11
|
-
});
|
|
12
|
-
vi.stubGlobal("sessionStorage", {
|
|
13
|
-
getItem: (key) => storage[key] ?? null,
|
|
14
|
-
setItem: (key, value) => {
|
|
15
|
-
storage[key] = value;
|
|
16
|
-
},
|
|
17
|
-
removeItem: (key) => {
|
|
18
|
-
delete storage[key];
|
|
19
|
-
},
|
|
20
|
-
});
|
|
21
|
-
vi.stubGlobal("navigator", {
|
|
22
|
-
sendBeacon: vi.fn().mockReturnValue(true),
|
|
23
|
-
});
|
|
24
|
-
vi.stubGlobal("document", {
|
|
25
|
-
addEventListener: vi.fn(),
|
|
26
|
-
referrer: "https://referrer.example",
|
|
27
|
-
title: "Settings",
|
|
28
|
-
});
|
|
29
|
-
vi.stubGlobal("window", {
|
|
30
|
-
addEventListener: vi.fn(),
|
|
31
|
-
});
|
|
32
|
-
vi.stubGlobal("location", {
|
|
33
|
-
href: "https://example.com/settings",
|
|
34
|
-
pathname: "/settings",
|
|
35
|
-
search: "?tab=profile",
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
it("attaches the current session ID to captured error events", async () => {
|
|
39
|
-
const client = new BrowserClient({
|
|
40
|
-
apiKey: "sq_pk_test",
|
|
41
|
-
projectId: "project-123",
|
|
42
|
-
enableVitals: false,
|
|
43
|
-
enableErrorCapture: false,
|
|
44
|
-
enableAutoBreadcrumbs: false,
|
|
45
|
-
});
|
|
46
|
-
client.captureError(new Error("boom"));
|
|
47
|
-
const sendBeaconMock = vi.mocked(navigator.sendBeacon);
|
|
48
|
-
expect(sendBeaconMock).toHaveBeenCalledOnce();
|
|
49
|
-
const [url, payloadBlob] = sendBeaconMock.mock.calls[0] ?? [];
|
|
50
|
-
expect(url).toBe("https://ingest.squasher.ai/v1/ingest/project-123?key=sq_pk_test");
|
|
51
|
-
expect(payloadBlob).toBeInstanceOf(Blob);
|
|
52
|
-
if (!(payloadBlob instanceof Blob)) {
|
|
53
|
-
throw new Error("expected ingest payload to be a Blob");
|
|
54
|
-
}
|
|
55
|
-
const payload = JSON.parse(await payloadBlob.text());
|
|
56
|
-
expect(payload.session_id).toBe("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
|
|
57
|
-
expect(payload.request?.url).toBe("https://example.com/settings");
|
|
58
|
-
client.close();
|
|
59
|
-
});
|
|
60
|
-
it("track sends analytics metadata with visitor context", async () => {
|
|
61
|
-
const client = new BrowserClient({
|
|
62
|
-
apiKey: "sq_pk_test",
|
|
63
|
-
projectId: "project-123",
|
|
64
|
-
enableVitals: false,
|
|
65
|
-
enableErrorCapture: false,
|
|
66
|
-
enableAutoBreadcrumbs: false,
|
|
67
|
-
});
|
|
68
|
-
client.track("checkout_started", { step: 1 });
|
|
69
|
-
const [_, payloadBlob] = vi.mocked(navigator.sendBeacon).mock.calls[0] ?? [];
|
|
70
|
-
if (!(payloadBlob instanceof Blob)) {
|
|
71
|
-
throw new Error("expected ingest payload to be a Blob");
|
|
72
|
-
}
|
|
73
|
-
const payload = JSON.parse(await payloadBlob.text());
|
|
74
|
-
expect(payload.kind).toBe("analytics");
|
|
75
|
-
expect(payload.event_name).toBe("checkout_started");
|
|
76
|
-
expect(payload.analytics?.event).toBe("checkout_started");
|
|
77
|
-
expect(payload.analytics?.properties?.step).toBe(1);
|
|
78
|
-
expect(payload.visitor?.anonymous_id).toBe("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
|
|
79
|
-
client.close();
|
|
80
|
-
});
|
|
81
|
-
it("page sends normalized page metadata", async () => {
|
|
82
|
-
const client = new BrowserClient({
|
|
83
|
-
apiKey: "sq_pk_test",
|
|
84
|
-
projectId: "project-123",
|
|
85
|
-
enableVitals: false,
|
|
86
|
-
enableErrorCapture: false,
|
|
87
|
-
enableAutoBreadcrumbs: false,
|
|
88
|
-
});
|
|
89
|
-
client.page("Settings");
|
|
90
|
-
const [_, payloadBlob] = vi.mocked(navigator.sendBeacon).mock.calls[0] ?? [];
|
|
91
|
-
if (!(payloadBlob instanceof Blob)) {
|
|
92
|
-
throw new Error("expected ingest payload to be a Blob");
|
|
93
|
-
}
|
|
94
|
-
const payload = JSON.parse(await payloadBlob.text());
|
|
95
|
-
expect(payload.kind).toBe("page");
|
|
96
|
-
expect(payload.page?.name).toBe("Settings");
|
|
97
|
-
expect(payload.page?.path).toBe("/settings");
|
|
98
|
-
expect(payload.page?.title).toBe("Settings");
|
|
99
|
-
expect(payload.page?.referrer).toBe("https://referrer.example");
|
|
100
|
-
expect(payload.page?.search).toBe("?tab=profile");
|
|
101
|
-
client.close();
|
|
102
|
-
});
|
|
103
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"errors.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/errors.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { parseStack, buildErrorEvent, buildMessageEvent } from "../errors";
|
|
3
|
-
describe("parseStack", () => {
|
|
4
|
-
it("parses V8 stack traces", () => {
|
|
5
|
-
const stack = `Error: Something went wrong
|
|
6
|
-
at functionName (https://example.com/app.js:10:20)
|
|
7
|
-
at anotherFunction (https://example.com/utils.js:5:3)
|
|
8
|
-
at Object.<anonymous> (node_modules/some-lib/index.js:100:15)`;
|
|
9
|
-
const frames = parseStack(stack);
|
|
10
|
-
expect(frames).toBeDefined();
|
|
11
|
-
expect(frames).toHaveLength(3);
|
|
12
|
-
expect(frames[0]).toEqual({
|
|
13
|
-
function: "functionName",
|
|
14
|
-
filename: "https://example.com/app.js",
|
|
15
|
-
lineno: 10,
|
|
16
|
-
colno: 20,
|
|
17
|
-
in_app: true,
|
|
18
|
-
});
|
|
19
|
-
expect(frames[1]).toEqual({
|
|
20
|
-
function: "anotherFunction",
|
|
21
|
-
filename: "https://example.com/utils.js",
|
|
22
|
-
lineno: 5,
|
|
23
|
-
colno: 3,
|
|
24
|
-
in_app: true,
|
|
25
|
-
});
|
|
26
|
-
// node_modules should be marked as not in_app
|
|
27
|
-
expect(frames[2].in_app).toBe(false);
|
|
28
|
-
});
|
|
29
|
-
it("handles anonymous functions", () => {
|
|
30
|
-
const stack = `Error: test
|
|
31
|
-
at https://example.com/app.js:1:1`;
|
|
32
|
-
const frames = parseStack(stack);
|
|
33
|
-
expect(frames).toBeDefined();
|
|
34
|
-
expect(frames).toHaveLength(1);
|
|
35
|
-
expect(frames[0].function).toBe("<anonymous>");
|
|
36
|
-
});
|
|
37
|
-
it("returns undefined for empty stack", () => {
|
|
38
|
-
expect(parseStack(undefined)).toBeUndefined();
|
|
39
|
-
expect(parseStack("Error: test")).toBeUndefined();
|
|
40
|
-
});
|
|
41
|
-
it("marks chrome extensions as not in_app", () => {
|
|
42
|
-
const stack = `Error: ext
|
|
43
|
-
at handler (chrome-extension://abcdef/content.js:5:10)`;
|
|
44
|
-
const frames = parseStack(stack);
|
|
45
|
-
expect(frames).toBeDefined();
|
|
46
|
-
expect(frames[0].in_app).toBe(false);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
describe("buildErrorEvent", () => {
|
|
50
|
-
it("builds a complete error event", () => {
|
|
51
|
-
const error = new Error("Test error");
|
|
52
|
-
error.name = "TypeError";
|
|
53
|
-
const event = buildErrorEvent(error, { extra: "data" });
|
|
54
|
-
expect(event.message).toBe("Test error");
|
|
55
|
-
expect(event.type).toBe("TypeError");
|
|
56
|
-
expect(event.level).toBe("error");
|
|
57
|
-
expect(event.sdk?.name).toBe("@squasher-ai/browser");
|
|
58
|
-
expect(event.sdk?.version).toBe("0.1.0");
|
|
59
|
-
expect(event.timestamp).toBeDefined();
|
|
60
|
-
expect(event.extra).toEqual({ extra: "data" });
|
|
61
|
-
expect(event.stack).toBeDefined();
|
|
62
|
-
});
|
|
63
|
-
it("builds event without extra", () => {
|
|
64
|
-
const error = new Error("Simple error");
|
|
65
|
-
const event = buildErrorEvent(error);
|
|
66
|
-
expect(event.extra).toBeUndefined();
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
describe("buildMessageEvent", () => {
|
|
70
|
-
it("builds a message event with default level", () => {
|
|
71
|
-
const event = buildMessageEvent("Hello");
|
|
72
|
-
expect(event.message).toBe("Hello");
|
|
73
|
-
expect(event.level).toBe("info");
|
|
74
|
-
expect(event.sdk?.name).toBe("@squasher-ai/browser");
|
|
75
|
-
});
|
|
76
|
-
it("builds a message event with custom level", () => {
|
|
77
|
-
const event = buildMessageEvent("Warning!", "warning");
|
|
78
|
-
expect(event.level).toBe("warning");
|
|
79
|
-
});
|
|
80
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"replay-privacy.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/replay-privacy.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { applyReplayEventPrivacy, buildReplayRecordOptions, MASK_ALL_TEXT_SELECTOR, } from "../replay-privacy";
|
|
3
|
-
describe("replay privacy helpers", () => {
|
|
4
|
-
it("builds rrweb options for text masking and blocked selectors", () => {
|
|
5
|
-
const emit = vi.fn();
|
|
6
|
-
expect(buildReplayRecordOptions({
|
|
7
|
-
emit,
|
|
8
|
-
privacy: {
|
|
9
|
-
blockSelector: ".pii-container",
|
|
10
|
-
maskAllText: true,
|
|
11
|
-
},
|
|
12
|
-
})).toMatchObject({
|
|
13
|
-
blockSelector: ".pii-container",
|
|
14
|
-
emit,
|
|
15
|
-
maskAllInputs: true,
|
|
16
|
-
maskAllText: true,
|
|
17
|
-
maskTextSelector: MASK_ALL_TEXT_SELECTOR,
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
it("redacts replay text fields without touching non-text attributes", () => {
|
|
21
|
-
const event = {
|
|
22
|
-
data: {
|
|
23
|
-
node: {
|
|
24
|
-
attributes: {
|
|
25
|
-
"aria-label": "Customer email",
|
|
26
|
-
placeholder: "you@example.com",
|
|
27
|
-
style: "color:#0f172a",
|
|
28
|
-
title: "Checkout form",
|
|
29
|
-
value: "Customer email",
|
|
30
|
-
},
|
|
31
|
-
childNodes: [
|
|
32
|
-
{
|
|
33
|
-
textContent: "Jane Example",
|
|
34
|
-
type: 3,
|
|
35
|
-
},
|
|
36
|
-
],
|
|
37
|
-
tagName: "input",
|
|
38
|
-
type: 2,
|
|
39
|
-
},
|
|
40
|
-
source: 5,
|
|
41
|
-
text: "customer@example.com",
|
|
42
|
-
},
|
|
43
|
-
timestamp: 1,
|
|
44
|
-
type: 3,
|
|
45
|
-
};
|
|
46
|
-
const redactedEvent = applyReplayEventPrivacy(event, { maskAllText: true });
|
|
47
|
-
const serializedEvent = JSON.stringify(redactedEvent);
|
|
48
|
-
expect(redactedEvent).not.toBe(event);
|
|
49
|
-
expect(serializedEvent).not.toContain("Jane Example");
|
|
50
|
-
expect(serializedEvent).not.toContain("you@example.com");
|
|
51
|
-
expect(serializedEvent).not.toContain("customer@example.com");
|
|
52
|
-
expect(serializedEvent).not.toContain("Checkout form");
|
|
53
|
-
expect(serializedEvent).toContain("color:#0f172a");
|
|
54
|
-
expect(serializedEvent).toContain("************");
|
|
55
|
-
expect(event.data.text).toBe("customer@example.com");
|
|
56
|
-
});
|
|
57
|
-
it("redacts inline style and script text alongside visible text nodes", () => {
|
|
58
|
-
const event = {
|
|
59
|
-
data: {
|
|
60
|
-
node: {
|
|
61
|
-
childNodes: [
|
|
62
|
-
{
|
|
63
|
-
childNodes: [
|
|
64
|
-
{
|
|
65
|
-
childNodes: [
|
|
66
|
-
{
|
|
67
|
-
tagName: "style",
|
|
68
|
-
childNodes: [
|
|
69
|
-
{
|
|
70
|
-
textContent: "body{background:#0f172a;color:#e2e8f0;}",
|
|
71
|
-
type: 3,
|
|
72
|
-
},
|
|
73
|
-
],
|
|
74
|
-
type: 2,
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
tagName: "script",
|
|
78
|
-
childNodes: [
|
|
79
|
-
{
|
|
80
|
-
textContent: 'window.__BOOTSTRAP__={email:"jane@example.com"};',
|
|
81
|
-
type: 3,
|
|
82
|
-
},
|
|
83
|
-
],
|
|
84
|
-
type: 2,
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
childNodes: [
|
|
88
|
-
{
|
|
89
|
-
textContent: "Private checkout summary",
|
|
90
|
-
type: 3,
|
|
91
|
-
},
|
|
92
|
-
],
|
|
93
|
-
tagName: "h1",
|
|
94
|
-
type: 2,
|
|
95
|
-
},
|
|
96
|
-
],
|
|
97
|
-
tagName: "body",
|
|
98
|
-
type: 2,
|
|
99
|
-
},
|
|
100
|
-
],
|
|
101
|
-
tagName: "html",
|
|
102
|
-
type: 2,
|
|
103
|
-
},
|
|
104
|
-
],
|
|
105
|
-
type: 0,
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
timestamp: 1,
|
|
109
|
-
type: 2,
|
|
110
|
-
};
|
|
111
|
-
const redactedEvent = applyReplayEventPrivacy(event, { maskAllText: true });
|
|
112
|
-
const serializedEvent = JSON.stringify(redactedEvent);
|
|
113
|
-
expect(serializedEvent).not.toContain("body{background:#0f172a;color:#e2e8f0;}");
|
|
114
|
-
expect(serializedEvent).not.toContain('window.__BOOTSTRAP__={email:"jane@example.com"};');
|
|
115
|
-
expect(serializedEvent).not.toContain("Private checkout summary");
|
|
116
|
-
expect(serializedEvent).toContain("******* ******** *******");
|
|
117
|
-
expect(serializedEvent).toMatch(/\*{10,}/);
|
|
118
|
-
});
|
|
119
|
-
it("returns the original replay event when text masking is disabled", () => {
|
|
120
|
-
const event = { data: { text: "customer@example.com" }, timestamp: 1, type: 3 };
|
|
121
|
-
expect(applyReplayEventPrivacy(event, { maskAllText: false })).toBe(event);
|
|
122
|
-
expect(applyReplayEventPrivacy(event)).toBe(event);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"replay-startup.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/replay-startup.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { ReplayRecorder } from "../replay";
|
|
3
|
-
import { resetSessionId } from "../session";
|
|
4
|
-
const rrwebRecordMock = vi.hoisted(() => vi.fn());
|
|
5
|
-
const rrwebStopMock = vi.hoisted(() => vi.fn());
|
|
6
|
-
vi.mock("rrweb", () => ({
|
|
7
|
-
record: rrwebRecordMock,
|
|
8
|
-
}));
|
|
9
|
-
function createReplayEvent(type, timestamp) {
|
|
10
|
-
return {
|
|
11
|
-
type,
|
|
12
|
-
timestamp,
|
|
13
|
-
data: {
|
|
14
|
-
source: type,
|
|
15
|
-
},
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
describe("ReplayRecorder startup behavior", () => {
|
|
19
|
-
let emitReplayEvent;
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
resetSessionId();
|
|
22
|
-
emitReplayEvent = undefined;
|
|
23
|
-
vi.useFakeTimers();
|
|
24
|
-
vi.restoreAllMocks();
|
|
25
|
-
const storage = {};
|
|
26
|
-
vi.stubGlobal("crypto", {
|
|
27
|
-
randomUUID: vi.fn().mockReturnValue("11111111-2222-4333-8444-555555555555"),
|
|
28
|
-
});
|
|
29
|
-
vi.stubGlobal("sessionStorage", {
|
|
30
|
-
getItem: (key) => storage[key] ?? null,
|
|
31
|
-
setItem: (key, value) => {
|
|
32
|
-
storage[key] = value;
|
|
33
|
-
},
|
|
34
|
-
removeItem: (key) => {
|
|
35
|
-
delete storage[key];
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
vi.stubGlobal("location", {
|
|
39
|
-
href: "https://example.com/dashboard",
|
|
40
|
-
});
|
|
41
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
|
|
42
|
-
vi.stubGlobal("navigator", {
|
|
43
|
-
sendBeacon: vi.fn().mockReturnValue(true),
|
|
44
|
-
});
|
|
45
|
-
vi.stubGlobal("document", {});
|
|
46
|
-
vi.stubGlobal("window", {
|
|
47
|
-
addEventListener: vi.fn(),
|
|
48
|
-
removeEventListener: vi.fn(),
|
|
49
|
-
});
|
|
50
|
-
rrwebStopMock.mockReset();
|
|
51
|
-
rrwebRecordMock.mockReset();
|
|
52
|
-
rrwebRecordMock.mockImplementation((options) => {
|
|
53
|
-
emitReplayEvent = options?.emit;
|
|
54
|
-
return rrwebStopMock;
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
afterEach(() => {
|
|
58
|
-
vi.useRealTimers();
|
|
59
|
-
vi.unstubAllGlobals();
|
|
60
|
-
});
|
|
61
|
-
it("preserves rrweb bootstrap events emitted before record() returns", async () => {
|
|
62
|
-
rrwebRecordMock.mockImplementation((options) => {
|
|
63
|
-
options?.emit?.(createReplayEvent(4, 1));
|
|
64
|
-
options?.emit?.(createReplayEvent(2, 2));
|
|
65
|
-
emitReplayEvent = options?.emit;
|
|
66
|
-
return rrwebStopMock;
|
|
67
|
-
});
|
|
68
|
-
const recorder = new ReplayRecorder({
|
|
69
|
-
endpoint: "https://ingest.squasher.ai",
|
|
70
|
-
projectId: "project-123",
|
|
71
|
-
apiKey: "sq_pk_test",
|
|
72
|
-
debug: false,
|
|
73
|
-
sampleRate: 1,
|
|
74
|
-
});
|
|
75
|
-
await recorder.ready;
|
|
76
|
-
emitReplayEvent?.(createReplayEvent(3, 3));
|
|
77
|
-
vi.advanceTimersByTime(5_000);
|
|
78
|
-
const requestBody = vi.mocked(fetch).mock.calls[0]?.[1]?.body;
|
|
79
|
-
expect(typeof requestBody).toBe("string");
|
|
80
|
-
if (typeof requestBody !== "string") {
|
|
81
|
-
throw new Error("expected replay request body to be a string");
|
|
82
|
-
}
|
|
83
|
-
const payload = JSON.parse(requestBody);
|
|
84
|
-
expect(payload.events.map((event) => event.type)).toEqual([4, 2, 3]);
|
|
85
|
-
recorder.dispose();
|
|
86
|
-
});
|
|
87
|
-
it("skips recording when the replay sample rate drops the session", () => {
|
|
88
|
-
vi.spyOn(Math, "random").mockReturnValue(0.9);
|
|
89
|
-
const recorder = new ReplayRecorder({
|
|
90
|
-
endpoint: "https://ingest.squasher.ai",
|
|
91
|
-
projectId: "project-123",
|
|
92
|
-
apiKey: "sq_pk_test",
|
|
93
|
-
debug: false,
|
|
94
|
-
sampleRate: 0.5,
|
|
95
|
-
});
|
|
96
|
-
expect(rrwebRecordMock).not.toHaveBeenCalled();
|
|
97
|
-
recorder.dispose();
|
|
98
|
-
});
|
|
99
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"replay.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/replay.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { ReplayRecorder } from "../replay";
|
|
3
|
-
import { resetSessionId } from "../session";
|
|
4
|
-
const rrwebRecordMock = vi.hoisted(() => vi.fn());
|
|
5
|
-
const rrwebStopMock = vi.hoisted(() => vi.fn());
|
|
6
|
-
vi.mock("rrweb", () => ({
|
|
7
|
-
record: rrwebRecordMock,
|
|
8
|
-
}));
|
|
9
|
-
function createReplayEvent(type, timestamp) {
|
|
10
|
-
return {
|
|
11
|
-
type,
|
|
12
|
-
timestamp,
|
|
13
|
-
data: {
|
|
14
|
-
source: type,
|
|
15
|
-
},
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
describe("ReplayRecorder", () => {
|
|
19
|
-
let listeners;
|
|
20
|
-
let emitReplayEvent;
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
resetSessionId();
|
|
23
|
-
listeners = {};
|
|
24
|
-
emitReplayEvent = undefined;
|
|
25
|
-
vi.useFakeTimers();
|
|
26
|
-
vi.restoreAllMocks();
|
|
27
|
-
const storage = {};
|
|
28
|
-
vi.stubGlobal("crypto", {
|
|
29
|
-
randomUUID: vi.fn().mockReturnValue("11111111-2222-4333-8444-555555555555"),
|
|
30
|
-
});
|
|
31
|
-
vi.stubGlobal("sessionStorage", {
|
|
32
|
-
getItem: (key) => storage[key] ?? null,
|
|
33
|
-
setItem: (key, value) => {
|
|
34
|
-
storage[key] = value;
|
|
35
|
-
},
|
|
36
|
-
removeItem: (key) => {
|
|
37
|
-
delete storage[key];
|
|
38
|
-
},
|
|
39
|
-
});
|
|
40
|
-
vi.stubGlobal("location", {
|
|
41
|
-
href: "https://example.com/dashboard",
|
|
42
|
-
});
|
|
43
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
|
|
44
|
-
vi.stubGlobal("navigator", {
|
|
45
|
-
sendBeacon: vi.fn().mockReturnValue(true),
|
|
46
|
-
});
|
|
47
|
-
vi.stubGlobal("document", {});
|
|
48
|
-
vi.stubGlobal("window", {
|
|
49
|
-
addEventListener: vi.fn((event, listener) => {
|
|
50
|
-
listeners[event] = [...(listeners[event] ?? []), listener];
|
|
51
|
-
}),
|
|
52
|
-
removeEventListener: vi.fn((event, listener) => {
|
|
53
|
-
listeners[event] = (listeners[event] ?? []).filter((value) => value !== listener);
|
|
54
|
-
}),
|
|
55
|
-
});
|
|
56
|
-
rrwebStopMock.mockReset();
|
|
57
|
-
rrwebRecordMock.mockReset();
|
|
58
|
-
rrwebRecordMock.mockImplementation((options) => {
|
|
59
|
-
emitReplayEvent = options?.emit;
|
|
60
|
-
return rrwebStopMock;
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
afterEach(() => {
|
|
64
|
-
vi.useRealTimers();
|
|
65
|
-
vi.unstubAllGlobals();
|
|
66
|
-
});
|
|
67
|
-
it("starts rrweb recording when replay is enabled", async () => {
|
|
68
|
-
const recorder = new ReplayRecorder({
|
|
69
|
-
endpoint: "https://ingest.squasher.ai",
|
|
70
|
-
projectId: "project-123",
|
|
71
|
-
apiKey: "sq_pk_test",
|
|
72
|
-
debug: false,
|
|
73
|
-
sampleRate: 1,
|
|
74
|
-
});
|
|
75
|
-
await recorder.ready;
|
|
76
|
-
expect(rrwebRecordMock).toHaveBeenCalledOnce();
|
|
77
|
-
recorder.dispose();
|
|
78
|
-
expect(rrwebStopMock).toHaveBeenCalledOnce();
|
|
79
|
-
});
|
|
80
|
-
it("flushes replay events once the buffer reaches 50 events", async () => {
|
|
81
|
-
const recorder = new ReplayRecorder({
|
|
82
|
-
endpoint: "https://ingest.squasher.ai",
|
|
83
|
-
projectId: "project-123",
|
|
84
|
-
apiKey: "sq_pk_test",
|
|
85
|
-
debug: false,
|
|
86
|
-
sampleRate: 1,
|
|
87
|
-
});
|
|
88
|
-
await recorder.ready;
|
|
89
|
-
for (let index = 0; index < 50; index += 1) {
|
|
90
|
-
emitReplayEvent?.(createReplayEvent(index === 0 ? 2 : 3, index));
|
|
91
|
-
}
|
|
92
|
-
const fetchMock = vi.mocked(fetch);
|
|
93
|
-
expect(fetchMock).toHaveBeenCalledOnce();
|
|
94
|
-
const [url, requestInit] = fetchMock.mock.calls[0] ?? [];
|
|
95
|
-
expect(url).toBe("https://ingest.squasher.ai/v1/projects/project-123/replays");
|
|
96
|
-
expect(requestInit?.headers).toEqual({
|
|
97
|
-
"Content-Type": "application/json",
|
|
98
|
-
"x-squasher-key": "sq_pk_test",
|
|
99
|
-
});
|
|
100
|
-
const requestBody = requestInit?.body;
|
|
101
|
-
expect(typeof requestBody).toBe("string");
|
|
102
|
-
if (typeof requestBody !== "string") {
|
|
103
|
-
throw new Error("expected replay request body to be a string");
|
|
104
|
-
}
|
|
105
|
-
const payload = JSON.parse(requestBody);
|
|
106
|
-
expect(payload.session_id).toBe("11111111-2222-4333-8444-555555555555");
|
|
107
|
-
expect(payload.page_url).toBe("https://example.com/dashboard");
|
|
108
|
-
expect(payload.events).toHaveLength(50);
|
|
109
|
-
expect(payload.events[0]?.type).toBe(2);
|
|
110
|
-
expect(payload.events[49]?.type).toBe(3);
|
|
111
|
-
recorder.dispose();
|
|
112
|
-
});
|
|
113
|
-
it("flushes queued replay events every five seconds", async () => {
|
|
114
|
-
const recorder = new ReplayRecorder({
|
|
115
|
-
endpoint: "https://ingest.squasher.ai",
|
|
116
|
-
projectId: "project-123",
|
|
117
|
-
apiKey: "sq_pk_test",
|
|
118
|
-
debug: false,
|
|
119
|
-
sampleRate: 1,
|
|
120
|
-
});
|
|
121
|
-
await recorder.ready;
|
|
122
|
-
emitReplayEvent?.(createReplayEvent(2, 1));
|
|
123
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
124
|
-
vi.advanceTimersByTime(5_000);
|
|
125
|
-
expect(fetch).toHaveBeenCalledOnce();
|
|
126
|
-
recorder.dispose();
|
|
127
|
-
});
|
|
128
|
-
it("uses sendBeacon for the final pagehide flush", async () => {
|
|
129
|
-
const recorder = new ReplayRecorder({
|
|
130
|
-
endpoint: "https://ingest.squasher.ai",
|
|
131
|
-
projectId: "project-123",
|
|
132
|
-
apiKey: "sq_pk_test",
|
|
133
|
-
debug: false,
|
|
134
|
-
sampleRate: 1,
|
|
135
|
-
});
|
|
136
|
-
await recorder.ready;
|
|
137
|
-
emitReplayEvent?.(createReplayEvent(3, 99));
|
|
138
|
-
listeners.pagehide?.forEach((listener) => listener());
|
|
139
|
-
const sendBeaconMock = vi.mocked(navigator.sendBeacon);
|
|
140
|
-
expect(sendBeaconMock).toHaveBeenCalledOnce();
|
|
141
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
142
|
-
const [url, payloadBlob] = sendBeaconMock.mock.calls[0] ?? [];
|
|
143
|
-
expect(url).toBe("https://ingest.squasher.ai/v1/projects/project-123/replays?key=sq_pk_test");
|
|
144
|
-
expect(payloadBlob).toBeInstanceOf(Blob);
|
|
145
|
-
if (!(payloadBlob instanceof Blob)) {
|
|
146
|
-
throw new Error("expected sendBeacon payload to be a Blob");
|
|
147
|
-
}
|
|
148
|
-
const payload = JSON.parse(await payloadBlob.text());
|
|
149
|
-
expect(payload.session_id).toBe("11111111-2222-4333-8444-555555555555");
|
|
150
|
-
expect(payload.events).toHaveLength(1);
|
|
151
|
-
expect(payload.events[0]?.type).toBe(3);
|
|
152
|
-
recorder.dispose();
|
|
153
|
-
});
|
|
154
|
-
it("passes privacy controls to rrweb and redacts masked text in serialized payloads", async () => {
|
|
155
|
-
const recorder = new ReplayRecorder({
|
|
156
|
-
endpoint: "https://ingest.squasher.ai",
|
|
157
|
-
projectId: "project-123",
|
|
158
|
-
apiKey: "sq_pk_test",
|
|
159
|
-
debug: false,
|
|
160
|
-
privacy: {
|
|
161
|
-
blockSelector: ".pii-container",
|
|
162
|
-
maskAllText: true,
|
|
163
|
-
},
|
|
164
|
-
sampleRate: 1,
|
|
165
|
-
});
|
|
166
|
-
await recorder.ready;
|
|
167
|
-
expect(rrwebRecordMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
168
|
-
blockSelector: ".pii-container",
|
|
169
|
-
maskAllInputs: true,
|
|
170
|
-
maskAllText: true,
|
|
171
|
-
maskTextSelector: "body, body *",
|
|
172
|
-
}));
|
|
173
|
-
emitReplayEvent?.({
|
|
174
|
-
data: {
|
|
175
|
-
node: {
|
|
176
|
-
attributes: {
|
|
177
|
-
placeholder: "customer@example.com",
|
|
178
|
-
value: "customer@example.com",
|
|
179
|
-
},
|
|
180
|
-
childNodes: [
|
|
181
|
-
{ textContent: "Jane Example", type: 3 },
|
|
182
|
-
{
|
|
183
|
-
childNodes: [{ textContent: "body{color:#0f172a;}", type: 3 }],
|
|
184
|
-
tagName: "style",
|
|
185
|
-
type: 2,
|
|
186
|
-
},
|
|
187
|
-
{
|
|
188
|
-
childNodes: [
|
|
189
|
-
{ textContent: 'window.__STATE__={email:"customer@example.com"};', type: 3 },
|
|
190
|
-
],
|
|
191
|
-
tagName: "script",
|
|
192
|
-
type: 2,
|
|
193
|
-
},
|
|
194
|
-
],
|
|
195
|
-
type: 2,
|
|
196
|
-
},
|
|
197
|
-
text: "customer@example.com",
|
|
198
|
-
},
|
|
199
|
-
timestamp: 1,
|
|
200
|
-
type: 3,
|
|
201
|
-
});
|
|
202
|
-
vi.advanceTimersByTime(5_000);
|
|
203
|
-
const requestBody = vi.mocked(fetch).mock.calls[0]?.[1]?.body;
|
|
204
|
-
expect(typeof requestBody).toBe("string");
|
|
205
|
-
if (typeof requestBody !== "string") {
|
|
206
|
-
throw new Error("expected replay request body to be a string");
|
|
207
|
-
}
|
|
208
|
-
const payload = JSON.parse(requestBody);
|
|
209
|
-
const serializedEvents = JSON.stringify(payload.events);
|
|
210
|
-
expect(serializedEvents).not.toContain("Jane Example");
|
|
211
|
-
expect(serializedEvents).not.toContain("customer@example.com");
|
|
212
|
-
expect(serializedEvents).not.toContain("body{color:#0f172a;}");
|
|
213
|
-
expect(serializedEvents).not.toContain('window.__STATE__={email:"customer@example.com"};');
|
|
214
|
-
expect(serializedEvents).toContain("************");
|
|
215
|
-
recorder.dispose();
|
|
216
|
-
});
|
|
217
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"session.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/session.test.ts"],"names":[],"mappings":""}
|