@drip-apex/sdk 0.1.0 → 0.3.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/README.md +5 -0
- package/dist/bundle-report.json +584 -226
- package/dist/define.cjs +123 -0
- package/dist/define.cjs.map +7 -0
- package/dist/define.mjs +102 -0
- package/dist/define.mjs.map +7 -0
- package/dist/drip.cjs +19283 -0
- package/dist/drip.cjs.map +7 -0
- package/dist/drip.debug.js +1 -1
- package/dist/drip.debug.js.map +1 -1
- package/dist/drip.heatmap.js +1 -1
- package/dist/drip.heatmap.js.map +1 -1
- package/dist/drip.js +1 -1
- package/dist/drip.js.map +1 -1
- package/dist/drip.live.js +1 -1
- package/dist/drip.live.js.map +1 -1
- package/dist/drip.mjs +19267 -0
- package/dist/drip.mjs.map +7 -0
- package/dist/drip.replay-recorder.js +2 -0
- package/dist/drip.replay-recorder.js.map +1 -0
- package/dist/editor.js +147 -142
- package/dist/editor.js.map +4 -4
- package/dist/flags.cjs +1788 -0
- package/dist/flags.cjs.map +7 -0
- package/dist/flags.mjs +1765 -0
- package/dist/flags.mjs.map +7 -0
- package/dist/index.mjs +12 -0
- package/dist/index.mjs.map +2 -2
- package/dist/metafiles/define.cjs.meta.json +28 -0
- package/dist/metafiles/define.mjs.meta.json +32 -0
- package/dist/metafiles/drip.cjs.meta.json +3193 -0
- package/dist/metafiles/drip.debug.meta.json +2859 -574
- package/dist/metafiles/drip.heatmap.meta.json +2732 -460
- package/dist/metafiles/drip.live.meta.json +2732 -460
- package/dist/metafiles/drip.meta.json +2859 -574
- package/dist/metafiles/drip.mjs.meta.json +3235 -0
- package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
- package/dist/metafiles/editor.meta.json +67 -27
- package/dist/metafiles/flags.cjs.meta.json +220 -0
- package/dist/metafiles/flags.mjs.meta.json +225 -0
- package/dist/metafiles/index.meta.json +2717 -491
- package/dist/metafiles/index.mjs.meta.json +14 -4
- package/dist/react/index.js +16636 -13533
- package/dist/react/index.js.map +4 -4
- package/dist/sri.json +20 -15
- package/dist/types/activation-runtime.d.ts +91 -0
- package/dist/types/analytics-runtime.d.ts +166 -0
- package/dist/types/antiflicker.d.ts +9 -3
- package/dist/types/assignment-cookie-sync.d.ts +31 -0
- package/dist/types/assignment-runtime.d.ts +257 -0
- package/dist/types/assignment-store.d.ts +4 -8
- package/dist/types/bucket.d.ts +1 -2
- package/dist/types/compact-store.d.ts +1 -0
- package/dist/types/config-runtime.d.ts +313 -0
- package/dist/types/consent-evaluation.d.ts +28 -0
- package/dist/types/css.d.ts +5 -3
- package/dist/types/debug-loader.d.ts +2 -0
- package/dist/types/decide-assignments.d.ts +31 -0
- package/dist/types/delivery-timing.d.ts +8 -0
- package/dist/types/early-interaction-capture.d.ts +79 -0
- package/dist/types/early-reassert.d.ts +32 -0
- package/dist/types/edge-runtime.d.ts +11 -0
- package/dist/types/editor/api.d.ts +3 -0
- package/dist/types/editor/controller.d.ts +3 -1
- package/dist/types/editor/publication-result.d.ts +11 -0
- package/dist/types/editor/types.d.ts +2 -1
- package/dist/types/editor/view-kit.d.ts +1 -1
- package/dist/types/endpoint.d.ts +8 -0
- package/dist/types/exclusion-groups.d.ts +4 -7
- package/dist/types/flags.d.ts +2 -0
- package/dist/types/goal-eligibility.d.ts +4 -0
- package/dist/types/goals.d.ts +10 -1
- package/dist/types/guards.d.ts +7 -7
- package/dist/types/handover.d.ts +12 -1
- package/dist/types/hash.d.ts +8 -4
- package/dist/types/hydration.d.ts +2 -1
- package/dist/types/index.d.ts +24 -128
- package/dist/types/init-test-helpers.d.ts +48 -0
- package/dist/types/inline-bootstrap.d.ts +26 -0
- package/dist/types/js.d.ts +3 -1
- package/dist/types/money.d.ts +12 -0
- package/dist/types/mutate.d.ts +45 -6
- package/dist/types/performance.d.ts +2 -0
- package/dist/types/persistence-decoders.d.ts +12 -0
- package/dist/types/public-api.d.ts +209 -0
- package/dist/types/qa-assistant-state.d.ts +34 -0
- package/dist/types/react/index.d.ts +28 -3
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/redirect.d.ts +48 -0
- package/dist/types/replay-recorder-entry.d.ts +1 -0
- package/dist/types/replay-signal-state.d.ts +6 -0
- package/dist/types/replay-signals.d.ts +13 -0
- package/dist/types/replay.d.ts +110 -0
- package/dist/types/router.d.ts +12 -2
- package/dist/types/runtime-state.d.ts +166 -0
- package/dist/types/sanitize.d.ts +1 -0
- package/dist/types/shadow.d.ts +3 -0
- package/dist/types/shopify-cart.d.ts +25 -1
- package/dist/types/storefront-vitals.d.ts +24 -0
- package/dist/types/targeting-diagnostics.d.ts +1 -0
- package/dist/types/targeting.d.ts +16 -2
- package/dist/types/track.d.ts +15 -1
- package/dist/types/types.d.ts +115 -9
- package/dist/types/wait.d.ts +14 -1
- package/dist/types/weekday-schedule.d.ts +3 -0
- package/package.json +17 -8
- package/src/activation-runtime.ts +2737 -0
- package/src/analytics-runtime.ts +2175 -0
- package/src/antiflicker.test.ts +216 -44
- package/src/antiflicker.ts +103 -28
- package/src/artifact-concurrency.test.ts +1091 -0
- package/src/assignment-cookie-sync.test.ts +202 -0
- package/src/assignment-cookie-sync.ts +189 -0
- package/src/assignment-runtime.ts +3204 -0
- package/src/assignment-store.test.ts +56 -0
- package/src/assignment-store.ts +7 -38
- package/src/bucket.ts +3 -18
- package/src/commerce-analytics-lifecycle.test.ts +75 -15
- package/src/commerce-config-lifecycle.test.ts +81 -1
- package/src/commerce-price-preserve.test.ts +133 -0
- package/src/commerce-price.test.ts +97 -0
- package/src/commerce-price.ts +74 -20
- package/src/commerce-shipping.test.ts +53 -0
- package/src/commerce-shipping.ts +5 -11
- package/src/compact-store.ts +15 -0
- package/src/config-fetch.test.ts +480 -9
- package/src/config-runtime.ts +2071 -0
- package/src/consent-evaluation-borlabs.test.ts +41 -0
- package/src/consent-evaluation-cookiescript.test.ts +50 -0
- package/src/consent-evaluation.ts +151 -0
- package/src/css.test.ts +30 -0
- package/src/css.ts +120 -6
- package/src/cwv.test.ts +60 -77
- package/src/debug-loader.test.ts +214 -0
- package/src/debug-loader.ts +41 -0
- package/src/decide-assignments.test.ts +196 -0
- package/src/decide-assignments.ts +204 -0
- package/src/delivery-timing.test.ts +104 -0
- package/src/delivery-timing.ts +89 -0
- package/src/early-interaction-capture.test.ts +451 -0
- package/src/early-interaction-capture.ts +230 -0
- package/src/early-reassert.test.ts +207 -0
- package/src/early-reassert.ts +152 -0
- package/src/edge-adoption.test.ts +435 -0
- package/src/edge-runtime.ts +30 -0
- package/src/editor/api.test.ts +109 -0
- package/src/editor/api.ts +34 -3
- package/src/editor/controller.test.ts +297 -14
- package/src/editor/controller.ts +69 -5
- package/src/editor/index.test.ts +2 -0
- package/src/editor/index.ts +1 -0
- package/src/editor/publication-result.ts +58 -0
- package/src/editor/types.ts +10 -1
- package/src/editor/view-kit.ts +2 -1
- package/src/editor/view.test.ts +108 -0
- package/src/editor/view.ts +49 -7
- package/src/endpoint.test.ts +15 -0
- package/src/endpoint.ts +10 -0
- package/src/exclusion-groups.ts +8 -18
- package/src/flags.test.ts +88 -1
- package/src/flags.ts +29 -29
- package/src/global-runtime-reconciler.test.ts +271 -0
- package/src/goal-eligibility.test.ts +29 -0
- package/src/goal-eligibility.ts +17 -0
- package/src/goals.test.ts +144 -1
- package/src/goals.ts +97 -18
- package/src/guards.test.ts +99 -1
- package/src/guards.ts +34 -15
- package/src/handover-cache-order.test.ts +6 -3
- package/src/handover.test.ts +154 -8
- package/src/handover.ts +122 -12
- package/src/hash.ts +29 -54
- package/src/holdout.test.ts +26 -0
- package/src/hydration.test.ts +73 -2
- package/src/hydration.ts +18 -9
- package/src/index.ts +130 -7287
- package/src/init-analytics.test.ts +1694 -0
- package/src/init-anti-flicker.test.ts +1211 -0
- package/src/init-cart-attributes.test.ts +2003 -0
- package/src/init-config-resolution.test.ts +999 -0
- package/src/init-consent.test.ts +971 -0
- package/src/init-empty.test.ts +11 -5406
- package/src/init-integrations.test.ts +846 -0
- package/src/init-page-activation.test.ts +5064 -0
- package/src/init-qa-loading.test.ts +740 -0
- package/src/init-redirects.test.ts +2156 -0
- package/src/init-test-helpers.ts +287 -0
- package/src/inline-bootstrap.test.ts +184 -0
- package/src/inline-bootstrap.ts +73 -0
- package/src/js.test.ts +51 -1
- package/src/js.ts +86 -2
- package/src/money.test.ts +55 -1
- package/src/money.ts +104 -2
- package/src/money.web-parity.contract.test.ts +85 -0
- package/src/mutate.test.ts +474 -10
- package/src/mutate.ts +739 -230
- package/src/page-trigger.test.ts +218 -8
- package/src/performance.ts +10 -0
- package/src/persistence-decoders.ts +18 -0
- package/src/public-api.ts +1828 -0
- package/src/qa-assistant-state.test.ts +249 -3
- package/src/qa-assistant-state.ts +146 -6
- package/src/qa-assistant-view.test.ts +220 -2
- package/src/qa-assistant-view.ts +102 -5
- package/src/react/index.test.tsx +37 -0
- package/src/react/index.tsx +19 -3
- package/src/reassert-sentinel.test.ts +155 -1
- package/src/reassert-sentinel.ts +168 -8
- package/src/redirect.test.ts +541 -0
- package/src/redirect.ts +572 -0
- package/src/replay-recorder-entry.ts +115 -0
- package/src/replay-signal-state.ts +100 -0
- package/src/replay-signals.test.ts +249 -0
- package/src/replay-signals.ts +151 -0
- package/src/replay.test.ts +764 -0
- package/src/replay.ts +514 -0
- package/src/router.test.ts +61 -2
- package/src/router.ts +50 -12
- package/src/runtime-state.ts +507 -0
- package/src/runtime.test.ts +174 -7
- package/src/runtime.ts +88 -14
- package/src/sanitize.test.ts +34 -0
- package/src/sanitize.ts +23 -12
- package/src/shadow.test.ts +187 -0
- package/src/shadow.ts +99 -0
- package/src/shopify-cart.test.ts +733 -23
- package/src/shopify-cart.ts +402 -49
- package/src/storefront-vitals.test.ts +217 -0
- package/src/storefront-vitals.ts +206 -0
- package/src/surface-signals.ts +6 -0
- package/src/targeting-conformance.test.ts +9 -0
- package/src/targeting-diagnostics.ts +38 -8
- package/src/targeting-parity-corpus.json +559 -0
- package/src/targeting-parity-corpus.test.ts +18 -0
- package/src/targeting.detailed.test.ts +105 -0
- package/src/targeting.test.ts +211 -0
- package/src/targeting.ts +88 -207
- package/src/track.test.ts +147 -0
- package/src/track.ts +125 -26
- package/src/types.ts +120 -13
- package/src/wait.test.ts +23 -0
- package/src/wait.ts +129 -13
- package/src/weekday-delivery.test.ts +56 -0
- package/src/weekday-schedule-parity-corpus.json +92 -0
- package/src/weekday-schedule.test.ts +69 -0
- package/src/weekday-schedule.ts +20 -0
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { deflateSync, inflateSync, strFromU8 } from "fflate";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { REPLAY_MAX_BLOCKS } from "@drip/event-schema/replay-contract";
|
|
5
|
+
import type { Assignment, Experiment } from "./types";
|
|
6
|
+
import {
|
|
7
|
+
REPLAY_FLUSH_INTERVAL_MS,
|
|
8
|
+
REPLAY_MAX_COMPRESSED_BYTES,
|
|
9
|
+
REPLAY_MAX_DURATION_MS,
|
|
10
|
+
REPLAY_PROGRESS_KEY_PREFIX,
|
|
11
|
+
resolveReplayAssignments,
|
|
12
|
+
resolveReplayRecorderUrl,
|
|
13
|
+
startReplayCapture,
|
|
14
|
+
type ReplayRecorderApi,
|
|
15
|
+
type ReplayRecorderEmission,
|
|
16
|
+
type ReplaySignals,
|
|
17
|
+
} from "./replay";
|
|
18
|
+
|
|
19
|
+
const rrwebMock = vi.hoisted(() => {
|
|
20
|
+
const stop = vi.fn();
|
|
21
|
+
const record = Object.assign(vi.fn(() => stop), {
|
|
22
|
+
mirror: { getNode: vi.fn() },
|
|
23
|
+
});
|
|
24
|
+
return { record, stop };
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
vi.mock("rrweb", () => ({ record: rrwebMock.record }));
|
|
28
|
+
|
|
29
|
+
const assignment: Assignment = {
|
|
30
|
+
experimentId: "exp_1",
|
|
31
|
+
variationId: "var_1",
|
|
32
|
+
index: 1,
|
|
33
|
+
mutations: [],
|
|
34
|
+
isControl: false,
|
|
35
|
+
hashValue: "0.25",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function experiment(overrides: Partial<Experiment> = {}): Experiment {
|
|
39
|
+
return {
|
|
40
|
+
id: "exp_1",
|
|
41
|
+
status: "running",
|
|
42
|
+
replayCapture: { enabled: true, sampleRate: 1 },
|
|
43
|
+
variations: [],
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function emptySignals(): ReplaySignals {
|
|
49
|
+
return {
|
|
50
|
+
clicks: [],
|
|
51
|
+
nav: [],
|
|
52
|
+
errors: [],
|
|
53
|
+
form_focus: [],
|
|
54
|
+
form_submit: [],
|
|
55
|
+
scroll_max_pct: 0,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fakeRecorder(overrides: Partial<ReplayRecorderApi> = {}) {
|
|
60
|
+
let emit: ((emission: ReplayRecorderEmission) => void) | null = null;
|
|
61
|
+
const stop = vi.fn();
|
|
62
|
+
const recorder: ReplayRecorderApi = {
|
|
63
|
+
version: "2.1.1",
|
|
64
|
+
start: vi.fn((options) => {
|
|
65
|
+
emit = options.emit;
|
|
66
|
+
return stop;
|
|
67
|
+
}),
|
|
68
|
+
compress: vi.fn(async (bytes) => deflateSync(bytes)),
|
|
69
|
+
compressSync: vi.fn((bytes) => deflateSync(bytes)),
|
|
70
|
+
takeSignals: vi.fn(() => emptySignals()),
|
|
71
|
+
...overrides,
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
recorder,
|
|
75
|
+
stop,
|
|
76
|
+
emit(emission: ReplayRecorderEmission) {
|
|
77
|
+
if (!emit) throw new Error("recorder not started");
|
|
78
|
+
emit(emission);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function replayOptions(recorder: ReplayRecorderApi, blocked: () => boolean) {
|
|
84
|
+
return {
|
|
85
|
+
assignments: [assignment],
|
|
86
|
+
experiments: [experiment()],
|
|
87
|
+
recorderUrl: "https://events.example.test/sdk/1/drip.replay-recorder.js",
|
|
88
|
+
endpoint: "https://events.example.test/events",
|
|
89
|
+
shopId: "shop_1",
|
|
90
|
+
visitorId: "visitor_1",
|
|
91
|
+
sessionId: "session_1",
|
|
92
|
+
environment: "production",
|
|
93
|
+
qaMode: true,
|
|
94
|
+
qaSessionId: "qa_1",
|
|
95
|
+
sdkVersion: "0.1.0+test",
|
|
96
|
+
ingestSignature: "signature_1",
|
|
97
|
+
isTrackingBlocked: blocked,
|
|
98
|
+
loadRecorder: vi.fn(async () => recorder),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("session replay capture", () => {
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
vi.useFakeTimers();
|
|
105
|
+
sessionStorage.clear();
|
|
106
|
+
history.replaceState(null, "", "/products/example");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
vi.restoreAllMocks();
|
|
111
|
+
vi.unstubAllGlobals();
|
|
112
|
+
vi.useRealTimers();
|
|
113
|
+
delete (globalThis as typeof globalThis & { __dripReplayRecorder?: ReplayRecorderApi }).__dripReplayRecorder;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("records dispatched form submits while active and removes the listener on stop", async () => {
|
|
117
|
+
vi.resetModules();
|
|
118
|
+
rrwebMock.record.mockClear();
|
|
119
|
+
rrwebMock.stop.mockClear();
|
|
120
|
+
document.body.innerHTML = `
|
|
121
|
+
<form id="checkout-form"><input id="email" /></form>
|
|
122
|
+
<form id="after-stop-form"><input /></form>
|
|
123
|
+
`;
|
|
124
|
+
const input = document.querySelector("#email")!;
|
|
125
|
+
rrwebMock.record.mirror.getNode.mockReturnValue(input);
|
|
126
|
+
|
|
127
|
+
await import("./replay-recorder-entry");
|
|
128
|
+
const recorder = (globalThis as typeof globalThis & { __dripReplayRecorder?: ReplayRecorderApi })
|
|
129
|
+
.__dripReplayRecorder!;
|
|
130
|
+
const stop = recorder.start({ emit: vi.fn() });
|
|
131
|
+
const rrwebEmit = rrwebMock.record.mock.calls[0]?.[0]?.emit;
|
|
132
|
+
rrwebEmit?.({ type: 3, timestamp: 1_000, data: { source: 2, type: 5, id: 7 } });
|
|
133
|
+
|
|
134
|
+
document.querySelector("#checkout-form")!.dispatchEvent(new Event("submit", { bubbles: true }));
|
|
135
|
+
|
|
136
|
+
expect(recorder.takeSignals()).toMatchObject({
|
|
137
|
+
form_focus: [{ ts: 1_000, selector: "form#checkout-form" }],
|
|
138
|
+
form_submit: [{ selector: "form#checkout-form" }],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
stop();
|
|
142
|
+
document.querySelector("#after-stop-form")!.dispatchEvent(new Event("submit", { bubbles: true }));
|
|
143
|
+
|
|
144
|
+
expect(recorder.takeSignals().form_submit).toHaveLength(1);
|
|
145
|
+
expect(rrwebMock.stop).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("keeps the per-assignment sample decision stable for the session", () => {
|
|
149
|
+
const random = vi.spyOn(Math, "random").mockReturnValueOnce(0.25).mockReturnValue(0.9);
|
|
150
|
+
const sampledExperiment = experiment({ replayCapture: { enabled: true, sampleRate: 0.5 } });
|
|
151
|
+
|
|
152
|
+
expect(resolveReplayAssignments([assignment], [sampledExperiment])).toEqual([
|
|
153
|
+
{ experiment_id: "exp_1", variation_id: "var_1" },
|
|
154
|
+
]);
|
|
155
|
+
expect(resolveReplayAssignments([assignment], [sampledExperiment])).toEqual([
|
|
156
|
+
{ experiment_id: "exp_1", variation_id: "var_1" },
|
|
157
|
+
]);
|
|
158
|
+
expect(sessionStorage.getItem("drip_rp:exp_1:var_1")).toBe("1");
|
|
159
|
+
expect(resolveReplayAssignments([assignment], [
|
|
160
|
+
experiment({ replayCapture: { enabled: true, sampleRate: 0 } }),
|
|
161
|
+
])).toHaveLength(1);
|
|
162
|
+
expect(random).toHaveBeenCalledTimes(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("does not load or post when consent blocks tracking", async () => {
|
|
166
|
+
const fake = fakeRecorder();
|
|
167
|
+
const options = replayOptions(fake.recorder, () => true);
|
|
168
|
+
const fetchMock = vi.fn();
|
|
169
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
170
|
+
|
|
171
|
+
await expect(startReplayCapture(options)).resolves.toBeNull();
|
|
172
|
+
expect(options.loadRecorder).not.toHaveBeenCalled();
|
|
173
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("does not start recording when consent is revoked while the recorder loads", async () => {
|
|
177
|
+
let blocked = false;
|
|
178
|
+
let finishLoad: ((recorder: ReplayRecorderApi) => void) | undefined;
|
|
179
|
+
const fake = fakeRecorder();
|
|
180
|
+
const options = replayOptions(fake.recorder, () => blocked);
|
|
181
|
+
options.loadRecorder = vi.fn(() => new Promise((resolve) => {
|
|
182
|
+
finishLoad = resolve;
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
const start = startReplayCapture(options);
|
|
186
|
+
blocked = true;
|
|
187
|
+
finishLoad?.(fake.recorder);
|
|
188
|
+
|
|
189
|
+
await expect(start).resolves.toBeNull();
|
|
190
|
+
expect(fake.recorder.start).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("does not start recording when SPA navigation enters checkout while the recorder loads", async () => {
|
|
194
|
+
let finishLoad: ((recorder: ReplayRecorderApi) => void) | undefined;
|
|
195
|
+
const fake = fakeRecorder();
|
|
196
|
+
const options = replayOptions(fake.recorder, () => false);
|
|
197
|
+
options.loadRecorder = vi.fn(() => new Promise((resolve) => {
|
|
198
|
+
finishLoad = resolve;
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
const start = startReplayCapture(options);
|
|
202
|
+
history.pushState(null, "", "/checkout");
|
|
203
|
+
finishLoad?.(fake.recorder);
|
|
204
|
+
|
|
205
|
+
await expect(start).resolves.toBeNull();
|
|
206
|
+
expect(fake.recorder.start).not.toHaveBeenCalled();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("does not start on checkout paths", async () => {
|
|
210
|
+
history.replaceState(null, "", "/checkouts/order-token");
|
|
211
|
+
const fake = fakeRecorder();
|
|
212
|
+
const options = replayOptions(fake.recorder, () => false);
|
|
213
|
+
|
|
214
|
+
await expect(startReplayCapture(options)).resolves.toBeNull();
|
|
215
|
+
expect(options.loadRecorder).not.toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("resolves the recorder beside immutable and alias SDK assets", () => {
|
|
219
|
+
expect(resolveReplayRecorderUrl({
|
|
220
|
+
scriptSrc: "https://events.example.test/sdk/0.1.0%2Babc/drip.live.js?x=1",
|
|
221
|
+
})).toBe("https://events.example.test/sdk/0.1.0%2Babc/drip.replay-recorder.js");
|
|
222
|
+
expect(resolveReplayRecorderUrl({
|
|
223
|
+
scriptSrc: "https://sdk.example.test/drip.js",
|
|
224
|
+
})).toBe("https://sdk.example.test/drip.replay-recorder.js");
|
|
225
|
+
expect(resolveReplayRecorderUrl({
|
|
226
|
+
scriptSrc: "https://events.example.test/sdk/1/drip.live.js?cache=1#fragment",
|
|
227
|
+
})).toBe("https://events.example.test/sdk/1/drip.replay-recorder.js");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("prefers a configured recorder URL and rejects unknown script paths", () => {
|
|
231
|
+
expect(resolveReplayRecorderUrl({
|
|
232
|
+
configuredUrl: "https://assets.example.test/custom-recorder.js",
|
|
233
|
+
scriptSrc: "https://events.example.test/sdk/1/drip.live.js",
|
|
234
|
+
})).toBe("https://assets.example.test/custom-recorder.js");
|
|
235
|
+
expect(resolveReplayRecorderUrl({
|
|
236
|
+
configuredUrl: "://malformed",
|
|
237
|
+
scriptSrc: "https://events.example.test/sdk/1/drip.live.js",
|
|
238
|
+
pageUrl: "https://shop.example.test/products/example",
|
|
239
|
+
})).toBe("https://events.example.test/sdk/1/drip.replay-recorder.js");
|
|
240
|
+
expect(resolveReplayRecorderUrl({
|
|
241
|
+
scriptSrc: "https://events.example.test/s/shop.js",
|
|
242
|
+
})).toBeNull();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("posts a contract-v1 deflate envelope with replay auth and QA fields", async () => {
|
|
246
|
+
const fake = fakeRecorder({
|
|
247
|
+
takeSignals: vi.fn(() => ({
|
|
248
|
+
...emptySignals(),
|
|
249
|
+
clicks: [{ ts: 1_000, selector: "button#buy", x: 20, y: 30, mut_ms: 120 }],
|
|
250
|
+
})),
|
|
251
|
+
});
|
|
252
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
253
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
254
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
255
|
+
expect(controller).not.toBeNull();
|
|
256
|
+
|
|
257
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: { href: location.href } } });
|
|
258
|
+
fake.emit({ event: { type: 3, timestamp: 1_120, data: { source: 0, adds: [] } } });
|
|
259
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
260
|
+
|
|
261
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
262
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
263
|
+
expect(url).toBe("https://events.example.test/replay-blocks");
|
|
264
|
+
expect(init).toMatchObject({
|
|
265
|
+
method: "POST",
|
|
266
|
+
keepalive: false,
|
|
267
|
+
headers: {
|
|
268
|
+
"Content-Type": "application/json",
|
|
269
|
+
"X-Drip-Shop": "shop_1",
|
|
270
|
+
"X-Drip-Signature": "signature_1",
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
const envelope = JSON.parse(String(init?.body));
|
|
274
|
+
expect(envelope).toMatchObject({
|
|
275
|
+
contract: 1,
|
|
276
|
+
shop_id: "shop_1",
|
|
277
|
+
visitor_id: "visitor_1",
|
|
278
|
+
session_id: "session_1",
|
|
279
|
+
seq: 0,
|
|
280
|
+
first_ts: 1_000,
|
|
281
|
+
last_ts: 1_120,
|
|
282
|
+
event_count: 2,
|
|
283
|
+
url: "http://localhost:3000/products/example",
|
|
284
|
+
environment: "production",
|
|
285
|
+
qa_mode: 1,
|
|
286
|
+
qa_session_id: "qa_1",
|
|
287
|
+
sdk_version: "0.1.0+test",
|
|
288
|
+
assignments: [{ experiment_id: "exp_1", variation_id: "var_1" }],
|
|
289
|
+
codec: "deflate",
|
|
290
|
+
signals: expect.objectContaining({ clicks: [expect.objectContaining({ mut_ms: 120 })] }),
|
|
291
|
+
});
|
|
292
|
+
const compressed = Uint8Array.from(atob(envelope.payload_b64), (character) => character.charCodeAt(0));
|
|
293
|
+
expect(JSON.parse(strFromU8(inflateSync(compressed)))).toHaveLength(2);
|
|
294
|
+
controller?.stop(true);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("uses keepalive for a small pagehide block", async () => {
|
|
298
|
+
const fake = fakeRecorder();
|
|
299
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
300
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
301
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
302
|
+
|
|
303
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
304
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
305
|
+
|
|
306
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
307
|
+
expect(fetchMock.mock.calls[0]?.[1]?.keepalive).toBe(true);
|
|
308
|
+
controller?.stop(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("posts an oversized pagehide block without keepalive", async () => {
|
|
312
|
+
const fake = fakeRecorder({
|
|
313
|
+
compressSync: vi.fn(() => new Uint8Array(45_000)),
|
|
314
|
+
});
|
|
315
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
316
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
317
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
318
|
+
|
|
319
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
320
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
321
|
+
|
|
322
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
323
|
+
expect(String(fetchMock.mock.calls[0]?.[1]?.body).length).toBeGreaterThan(60_000);
|
|
324
|
+
expect(fetchMock.mock.calls[0]?.[1]?.keepalive).toBe(false);
|
|
325
|
+
controller?.stop(true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("disables keepalive when multibyte signal text makes the body exceed 60KB", async () => {
|
|
329
|
+
const multibyteClicks = Array.from({ length: 20 }, (_, index) => ({
|
|
330
|
+
ts: index + 1,
|
|
331
|
+
selector: `button[data-label="${"é".repeat(480)}-${index}"]`,
|
|
332
|
+
x: index,
|
|
333
|
+
y: index,
|
|
334
|
+
}));
|
|
335
|
+
const fake = fakeRecorder({
|
|
336
|
+
compressSync: vi.fn(() => new Uint8Array(32_000)),
|
|
337
|
+
takeSignals: vi.fn(() => ({ ...emptySignals(), clicks: multibyteClicks })),
|
|
338
|
+
});
|
|
339
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
340
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
341
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
342
|
+
|
|
343
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
344
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
345
|
+
|
|
346
|
+
const body = String(fetchMock.mock.calls[0]?.[1]?.body);
|
|
347
|
+
expect(body.length).toBeLessThan(60_000);
|
|
348
|
+
expect(new TextEncoder().encode(body).length).toBeGreaterThanOrEqual(60_000);
|
|
349
|
+
expect(fetchMock.mock.calls[0]?.[1]?.keepalive).toBe(false);
|
|
350
|
+
controller?.stop(true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("caps replay envelope URLs before and after a block flush", async () => {
|
|
354
|
+
const fake = fakeRecorder();
|
|
355
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
356
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
357
|
+
history.replaceState(null, "", `/products/${"a".repeat(2_100)}`);
|
|
358
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
359
|
+
|
|
360
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
361
|
+
await vi.advanceTimersByTimeAsync(REPLAY_FLUSH_INTERVAL_MS);
|
|
362
|
+
history.replaceState(null, "", `/collections/${"b".repeat(2_100)}`);
|
|
363
|
+
fake.emit({ event: { type: 4, timestamp: 2_000, data: {} } });
|
|
364
|
+
await vi.advanceTimersByTimeAsync(REPLAY_FLUSH_INTERVAL_MS);
|
|
365
|
+
|
|
366
|
+
const urls = fetchMock.mock.calls.map((call) => JSON.parse(String(call[1]?.body)).url);
|
|
367
|
+
expect(urls).toHaveLength(2);
|
|
368
|
+
expect(urls[0]).toHaveLength(2_000);
|
|
369
|
+
expect(urls[1]).toHaveLength(2_000);
|
|
370
|
+
controller?.stop(true);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("uses keepalive for a small visibilitychange-hidden block and persists progress", async () => {
|
|
374
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
375
|
+
const fake = fakeRecorder();
|
|
376
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
377
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
378
|
+
vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
|
|
379
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
380
|
+
|
|
381
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z"));
|
|
382
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
383
|
+
document.dispatchEvent(new Event("visibilitychange"));
|
|
384
|
+
|
|
385
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
386
|
+
expect(fetchMock.mock.calls[0]?.[1]?.keepalive).toBe(true);
|
|
387
|
+
expect(fake.recorder.compressSync).toHaveBeenCalledTimes(1);
|
|
388
|
+
expect(JSON.parse(sessionStorage.getItem(`${REPLAY_PROGRESS_KEY_PREFIX}session_1`)!))
|
|
389
|
+
.toMatchObject({ elapsedMs: 2_000, nextSeq: 1, terminal: false });
|
|
390
|
+
controller?.stop(true);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("mirrors an edge-routed events endpoint at /_drip/replay-blocks", async () => {
|
|
394
|
+
const fake = fakeRecorder();
|
|
395
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
396
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
397
|
+
const options = replayOptions(fake.recorder, () => false);
|
|
398
|
+
options.endpoint = "https://shop.example.test/_drip/events";
|
|
399
|
+
const controller = await startReplayCapture(options);
|
|
400
|
+
|
|
401
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
402
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
403
|
+
|
|
404
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
405
|
+
"https://shop.example.test/_drip/replay-blocks",
|
|
406
|
+
);
|
|
407
|
+
controller?.stop(true);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("keeps every replay-sampled assignment seen during the session", async () => {
|
|
411
|
+
const fake = fakeRecorder();
|
|
412
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
413
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
414
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
415
|
+
const secondAssignment = {
|
|
416
|
+
...assignment,
|
|
417
|
+
experimentId: "exp_2",
|
|
418
|
+
variationId: "var_2",
|
|
419
|
+
};
|
|
420
|
+
controller?.update(
|
|
421
|
+
[secondAssignment],
|
|
422
|
+
[experiment({ id: "exp_2" })],
|
|
423
|
+
);
|
|
424
|
+
controller?.update([], []);
|
|
425
|
+
|
|
426
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
427
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
428
|
+
|
|
429
|
+
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)).assignments).toEqual([
|
|
430
|
+
{ experiment_id: "exp_1", variation_id: "var_1" },
|
|
431
|
+
{ experiment_id: "exp_2", variation_id: "var_2" },
|
|
432
|
+
]);
|
|
433
|
+
expect(fake.stop).not.toHaveBeenCalled();
|
|
434
|
+
controller?.stop(true);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("stops without posting when a compressed block exceeds the payload cap", async () => {
|
|
438
|
+
const oversized = new Uint8Array(750_001);
|
|
439
|
+
const fake = fakeRecorder({
|
|
440
|
+
compress: vi.fn(async () => oversized),
|
|
441
|
+
compressSync: vi.fn(() => oversized),
|
|
442
|
+
});
|
|
443
|
+
const fetchMock = vi.fn();
|
|
444
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
445
|
+
await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
446
|
+
|
|
447
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
448
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
449
|
+
|
|
450
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
451
|
+
expect(fake.stop).toHaveBeenCalledTimes(1);
|
|
452
|
+
expect(JSON.parse(sessionStorage.getItem(`${REPLAY_PROGRESS_KEY_PREFIX}session_1`)!))
|
|
453
|
+
.toMatchObject({ nextSeq: 1, terminal: false });
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("flushes when uncompressed JSON reaches 512KB", async () => {
|
|
457
|
+
const fake = fakeRecorder({
|
|
458
|
+
compress: vi.fn(async () => new Uint8Array([1])),
|
|
459
|
+
});
|
|
460
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
461
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
462
|
+
await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
463
|
+
|
|
464
|
+
fake.emit({
|
|
465
|
+
event: { type: 4, timestamp: 1_000, data: { text: "x".repeat(512 * 1024) } },
|
|
466
|
+
});
|
|
467
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
468
|
+
|
|
469
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("continues queued flushes after a compression rejection", async () => {
|
|
473
|
+
const compress = vi.fn()
|
|
474
|
+
.mockRejectedValueOnce(new Error("compression failed"))
|
|
475
|
+
.mockResolvedValue(new Uint8Array([1]));
|
|
476
|
+
const fake = fakeRecorder({ compress });
|
|
477
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
478
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
479
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
480
|
+
|
|
481
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
482
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
483
|
+
fake.emit({ event: { type: 4, timestamp: 2_000, data: {} } });
|
|
484
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
485
|
+
|
|
486
|
+
expect(compress).toHaveBeenCalledTimes(2);
|
|
487
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
488
|
+
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)).seq).toBe(1);
|
|
489
|
+
controller?.stop(true);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("reserves the next sequence before compression and resumes after reload", async () => {
|
|
493
|
+
const first = fakeRecorder({
|
|
494
|
+
compress: vi.fn(async () => {
|
|
495
|
+
throw new Error("compression failed");
|
|
496
|
+
}),
|
|
497
|
+
});
|
|
498
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 204 })));
|
|
499
|
+
const firstController = await startReplayCapture(replayOptions(first.recorder, () => false));
|
|
500
|
+
|
|
501
|
+
first.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
502
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
503
|
+
|
|
504
|
+
const second = fakeRecorder();
|
|
505
|
+
const secondController = await startReplayCapture(replayOptions(second.recorder, () => false));
|
|
506
|
+
second.emit({ event: { type: 4, timestamp: 2_000, data: {} } });
|
|
507
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
508
|
+
|
|
509
|
+
const fetchMock = vi.mocked(fetch);
|
|
510
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
511
|
+
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)).seq).toBe(1);
|
|
512
|
+
firstController?.stop(true);
|
|
513
|
+
secondController?.stop(true);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("resumes with the next sequence after consent revoke and re-grant", async () => {
|
|
517
|
+
let blocked = false;
|
|
518
|
+
const first = fakeRecorder();
|
|
519
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
520
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
521
|
+
const options = replayOptions(first.recorder, () => blocked);
|
|
522
|
+
const firstController = await startReplayCapture(options);
|
|
523
|
+
|
|
524
|
+
first.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
525
|
+
await vi.advanceTimersByTimeAsync(REPLAY_FLUSH_INTERVAL_MS);
|
|
526
|
+
blocked = true;
|
|
527
|
+
firstController?.update([assignment], [experiment()]);
|
|
528
|
+
|
|
529
|
+
expect(firstController?.isStopped()).toBe(true);
|
|
530
|
+
expect(JSON.parse(sessionStorage.getItem(`${REPLAY_PROGRESS_KEY_PREFIX}session_1`)!))
|
|
531
|
+
.toMatchObject({ nextSeq: 1, terminal: false });
|
|
532
|
+
|
|
533
|
+
blocked = false;
|
|
534
|
+
const second = fakeRecorder();
|
|
535
|
+
const secondController = await startReplayCapture({
|
|
536
|
+
...options,
|
|
537
|
+
loadRecorder: vi.fn(async () => second.recorder),
|
|
538
|
+
});
|
|
539
|
+
second.emit({ event: { type: 4, timestamp: 2_000, data: {} } });
|
|
540
|
+
await vi.advanceTimersByTimeAsync(REPLAY_FLUSH_INTERVAL_MS);
|
|
541
|
+
|
|
542
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
543
|
+
expect(fetchMock.mock.calls.map((call) => JSON.parse(String(call[1]?.body)).seq))
|
|
544
|
+
.toEqual([0, 1]);
|
|
545
|
+
secondController?.stop(true);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("restores compressed-byte progress and blocks a cross-page overrun", async () => {
|
|
549
|
+
const compressed = new Uint8Array(700_000);
|
|
550
|
+
sessionStorage.setItem(`${REPLAY_PROGRESS_KEY_PREFIX}session_1`, JSON.stringify({
|
|
551
|
+
nextSeq: 5,
|
|
552
|
+
totalCompressedBytes: REPLAY_MAX_COMPRESSED_BYTES - 699_999,
|
|
553
|
+
elapsedMs: 0,
|
|
554
|
+
terminal: false,
|
|
555
|
+
form_focus: [],
|
|
556
|
+
form_submit: [],
|
|
557
|
+
}));
|
|
558
|
+
const fake = fakeRecorder({
|
|
559
|
+
compress: vi.fn(async () => compressed),
|
|
560
|
+
});
|
|
561
|
+
const fetchMock = vi.fn();
|
|
562
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
563
|
+
await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
564
|
+
|
|
565
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
566
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
567
|
+
|
|
568
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
569
|
+
expect(fake.stop).toHaveBeenCalledTimes(1);
|
|
570
|
+
expect(JSON.parse(sessionStorage.getItem(`${REPLAY_PROGRESS_KEY_PREFIX}session_1`)!))
|
|
571
|
+
.toMatchObject({ nextSeq: 6, terminal: true });
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("uses the remaining persisted duration and records empty pagehide progress", async () => {
|
|
575
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
576
|
+
const first = fakeRecorder();
|
|
577
|
+
const firstController = await startReplayCapture(replayOptions(first.recorder, () => false));
|
|
578
|
+
await vi.advanceTimersByTimeAsync(5 * 60 * 1_000);
|
|
579
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
580
|
+
expect(JSON.parse(sessionStorage.getItem(`${REPLAY_PROGRESS_KEY_PREFIX}session_1`)!))
|
|
581
|
+
.toMatchObject({ elapsedMs: 5 * 60 * 1_000, terminal: false });
|
|
582
|
+
|
|
583
|
+
const second = fakeRecorder();
|
|
584
|
+
const secondController = await startReplayCapture(replayOptions(second.recorder, () => false));
|
|
585
|
+
await vi.advanceTimersByTimeAsync(REPLAY_MAX_DURATION_MS - 5 * 60 * 1_000 - 1);
|
|
586
|
+
expect(second.stop).not.toHaveBeenCalled();
|
|
587
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
588
|
+
expect(second.stop).toHaveBeenCalledTimes(1);
|
|
589
|
+
firstController?.stop(true);
|
|
590
|
+
secondController?.stop(true);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("normalizes and hands the full persisted signal track to the recorder", async () => {
|
|
594
|
+
sessionStorage.setItem(`${REPLAY_PROGRESS_KEY_PREFIX}session_1`, JSON.stringify({
|
|
595
|
+
clicks: [{ ts: 1, selector: " button#buy ", x: 10, y: 20, mut_ms: 120 }],
|
|
596
|
+
nav: [{ ts: 2, url: "https://shop.example.test/entry" }],
|
|
597
|
+
errors: [{ ts: 3, message: "Window error" }],
|
|
598
|
+
form_focus: [
|
|
599
|
+
{ ts: 10, selector: " form#checkout " },
|
|
600
|
+
{ ts: 11, selector: "form#checkout" },
|
|
601
|
+
{ ts: null, selector: "form#invalid" },
|
|
602
|
+
],
|
|
603
|
+
form_submit: [{ ts: 20, selector: "form#checkout" }],
|
|
604
|
+
scroll_max_pct: 75,
|
|
605
|
+
}));
|
|
606
|
+
const fake = fakeRecorder();
|
|
607
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
608
|
+
|
|
609
|
+
expect(fake.recorder.start).toHaveBeenCalledWith(expect.objectContaining({
|
|
610
|
+
signalState: {
|
|
611
|
+
clicks: [{ ts: 1, selector: "button#buy", x: 10, y: 20, mut_ms: 120 }],
|
|
612
|
+
nav: [{ ts: 2, url: "https://shop.example.test/entry" }],
|
|
613
|
+
errors: [{ ts: 3, message: "Window error" }],
|
|
614
|
+
form_focus: [{ ts: 10, selector: "form#checkout" }],
|
|
615
|
+
form_submit: [{ ts: 20, selector: "form#checkout" }],
|
|
616
|
+
scroll_max_pct: 75,
|
|
617
|
+
},
|
|
618
|
+
}));
|
|
619
|
+
controller?.stop(true);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("persists the full cumulative signal track from recorder snapshots", async () => {
|
|
623
|
+
const fake = fakeRecorder({
|
|
624
|
+
takeSignals: vi.fn(() => ({
|
|
625
|
+
clicks: [{ ts: 1, selector: "button#buy", x: 10, y: 20, mut_ms: 120 }],
|
|
626
|
+
nav: [{ ts: 2, url: "https://shop.example.test/entry" }],
|
|
627
|
+
errors: [{ ts: 3, message: "Window error" }],
|
|
628
|
+
form_focus: [{ ts: 10, selector: "form#checkout" }],
|
|
629
|
+
form_submit: [{ ts: 20, selector: "form#checkout" }],
|
|
630
|
+
scroll_max_pct: 75,
|
|
631
|
+
})),
|
|
632
|
+
});
|
|
633
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
634
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
635
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
636
|
+
|
|
637
|
+
expect(JSON.parse(sessionStorage.getItem(`${REPLAY_PROGRESS_KEY_PREFIX}session_1`)!))
|
|
638
|
+
.toMatchObject({
|
|
639
|
+
clicks: [{ ts: 1, selector: "button#buy", x: 10, y: 20, mut_ms: 120 }],
|
|
640
|
+
nav: [{ ts: 2, url: "https://shop.example.test/entry" }],
|
|
641
|
+
errors: [{ ts: 3, message: "Window error" }],
|
|
642
|
+
form_focus: [{ ts: 10, selector: "form#checkout" }],
|
|
643
|
+
form_submit: [{ ts: 20, selector: "form#checkout" }],
|
|
644
|
+
scroll_max_pct: 75,
|
|
645
|
+
});
|
|
646
|
+
controller?.stop(true);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("degrades to page-local capture when session storage is unavailable", async () => {
|
|
650
|
+
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
|
651
|
+
throw new DOMException("blocked", "SecurityError");
|
|
652
|
+
});
|
|
653
|
+
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
|
654
|
+
throw new DOMException("quota", "QuotaExceededError");
|
|
655
|
+
});
|
|
656
|
+
const fake = fakeRecorder();
|
|
657
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
658
|
+
|
|
659
|
+
expect(controller).not.toBeNull();
|
|
660
|
+
expect(fake.recorder.start).toHaveBeenCalledTimes(1);
|
|
661
|
+
expect(() => controller?.stop(true)).not.toThrow();
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("refuses to reload a terminal replay session", async () => {
|
|
665
|
+
const first = fakeRecorder();
|
|
666
|
+
const controller = await startReplayCapture(replayOptions(first.recorder, () => false));
|
|
667
|
+
controller?.stop();
|
|
668
|
+
|
|
669
|
+
const second = fakeRecorder();
|
|
670
|
+
const options = replayOptions(second.recorder, () => false);
|
|
671
|
+
await expect(startReplayCapture(options)).resolves.toBeNull();
|
|
672
|
+
expect(options.loadRecorder).not.toHaveBeenCalled();
|
|
673
|
+
expect(second.recorder.start).not.toHaveBeenCalled();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("stops before total compressed data exceeds 4MB", async () => {
|
|
677
|
+
const compressed = new Uint8Array(700_000);
|
|
678
|
+
const fake = fakeRecorder({
|
|
679
|
+
compress: vi.fn(async () => compressed),
|
|
680
|
+
compressSync: vi.fn(() => compressed),
|
|
681
|
+
});
|
|
682
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
683
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
684
|
+
await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
685
|
+
|
|
686
|
+
for (let seq = 0; seq < 6; seq += 1) {
|
|
687
|
+
fake.emit({ event: { type: 4, timestamp: seq + 1, data: {} } });
|
|
688
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
expect(fetchMock).toHaveBeenCalledTimes(5);
|
|
692
|
+
expect(fake.stop).toHaveBeenCalledTimes(1);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("stops emission at the shared replay block cap", async () => {
|
|
696
|
+
const fake = fakeRecorder({
|
|
697
|
+
compress: vi.fn(async () => new Uint8Array([1])),
|
|
698
|
+
compressSync: vi.fn(() => new Uint8Array([1])),
|
|
699
|
+
});
|
|
700
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
701
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
702
|
+
await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
703
|
+
|
|
704
|
+
for (let seq = 0; seq <= REPLAY_MAX_BLOCKS; seq += 1) {
|
|
705
|
+
fake.emit({ event: { type: 4, timestamp: seq + 1, data: {} } });
|
|
706
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
expect(fetchMock).toHaveBeenCalledTimes(REPLAY_MAX_BLOCKS);
|
|
710
|
+
expect(JSON.parse(String(fetchMock.mock.calls.at(-1)?.[1]?.body)).seq)
|
|
711
|
+
.toBe(REPLAY_MAX_BLOCKS - 1);
|
|
712
|
+
expect(fake.stop).toHaveBeenCalledTimes(1);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("flushes and stops at the 15-minute duration cap", async () => {
|
|
716
|
+
const fake = fakeRecorder();
|
|
717
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
718
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
719
|
+
await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
720
|
+
|
|
721
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
722
|
+
await vi.advanceTimersByTimeAsync(15 * 60 * 1_000);
|
|
723
|
+
|
|
724
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
725
|
+
expect(fake.stop).toHaveBeenCalledTimes(1);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("stops and discards buffered events when consent is revoked", async () => {
|
|
729
|
+
let blocked = false;
|
|
730
|
+
const fake = fakeRecorder();
|
|
731
|
+
const fetchMock = vi.fn();
|
|
732
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
733
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => blocked));
|
|
734
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: {} } });
|
|
735
|
+
|
|
736
|
+
blocked = true;
|
|
737
|
+
controller?.update([assignment], [experiment()]);
|
|
738
|
+
await vi.advanceTimersByTimeAsync(20_000);
|
|
739
|
+
|
|
740
|
+
expect(fake.stop).toHaveBeenCalledTimes(1);
|
|
741
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
742
|
+
expect(controller?.isStopped()).toBe(true);
|
|
743
|
+
expect(JSON.parse(sessionStorage.getItem(`${REPLAY_PROGRESS_KEY_PREFIX}session_1`)!))
|
|
744
|
+
.toMatchObject({ terminal: false });
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("stops and discards the partial block after SPA navigation into checkout", async () => {
|
|
748
|
+
const fake = fakeRecorder();
|
|
749
|
+
const fetchMock = vi.fn();
|
|
750
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
751
|
+
const controller = await startReplayCapture(replayOptions(fake.recorder, () => false));
|
|
752
|
+
|
|
753
|
+
fake.emit({ event: { type: 4, timestamp: 1_000, data: { href: location.href } } });
|
|
754
|
+
history.pushState(null, "", "/checkout");
|
|
755
|
+
fake.emit({ event: { type: 4, timestamp: 1_150, data: { href: location.href } } });
|
|
756
|
+
await vi.advanceTimersByTimeAsync(20_000);
|
|
757
|
+
|
|
758
|
+
expect(fake.stop).toHaveBeenCalledTimes(1);
|
|
759
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
760
|
+
expect(controller?.isStopped()).toBe(true);
|
|
761
|
+
expect(JSON.parse(sessionStorage.getItem(`${REPLAY_PROGRESS_KEY_PREFIX}session_1`)!))
|
|
762
|
+
.toMatchObject({ terminal: false });
|
|
763
|
+
});
|
|
764
|
+
});
|