@drip-apex/sdk 0.1.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 -0
- package/README.md +46 -0
- package/dist/_headers +3 -0
- package/dist/bundle-report.json +564 -0
- package/dist/drip.debug.js +2 -0
- package/dist/drip.debug.js.map +1 -0
- package/dist/drip.heatmap.js +2 -0
- package/dist/drip.heatmap.js.map +1 -0
- package/dist/drip.js +2 -0
- package/dist/drip.js.map +1 -0
- package/dist/drip.live.js +2 -0
- package/dist/drip.live.js.map +1 -0
- package/dist/editor.js +480 -0
- package/dist/editor.js.map +7 -0
- package/dist/index.mjs +645 -0
- package/dist/index.mjs.map +7 -0
- package/dist/metafiles/drip.debug.meta.json +973 -0
- package/dist/metafiles/drip.heatmap.meta.json +918 -0
- package/dist/metafiles/drip.live.meta.json +918 -0
- package/dist/metafiles/drip.meta.json +973 -0
- package/dist/metafiles/editor.meta.json +321 -0
- package/dist/metafiles/index.meta.json +1039 -0
- package/dist/metafiles/index.mjs.meta.json +78 -0
- package/dist/react/index.js +16973 -0
- package/dist/react/index.js.map +7 -0
- package/dist/sri.json +27 -0
- package/dist/types/analytics.d.ts +15 -0
- package/dist/types/antiflicker.d.ts +14 -0
- package/dist/types/assignment-store.d.ts +30 -0
- package/dist/types/audit.d.ts +9 -0
- package/dist/types/bucket.d.ts +2 -0
- package/dist/types/bucketing-cleanup.d.ts +5 -0
- package/dist/types/commerce-gift.d.ts +41 -0
- package/dist/types/commerce-price.d.ts +22 -0
- package/dist/types/commerce-shipping.d.ts +28 -0
- package/dist/types/css.d.ts +5 -0
- package/dist/types/cwv.d.ts +10 -0
- package/dist/types/data-layer-normalize.d.ts +1 -0
- package/dist/types/debug.d.ts +23 -0
- package/dist/types/define.d.ts +87 -0
- package/dist/types/devtools.d.ts +32 -0
- package/dist/types/dom-trigger.d.ts +3 -0
- package/dist/types/editor/api.d.ts +37 -0
- package/dist/types/editor/artifacts.d.ts +15 -0
- package/dist/types/editor/command-runtime.d.ts +48 -0
- package/dist/types/editor/controller.d.ts +97 -0
- package/dist/types/editor/device-preview.d.ts +23 -0
- package/dist/types/editor/index.d.ts +9 -0
- package/dist/types/editor/operator.d.ts +39 -0
- package/dist/types/editor/picker.d.ts +23 -0
- package/dist/types/editor/selector.d.ts +2 -0
- package/dist/types/editor/types.d.ts +55 -0
- package/dist/types/editor/view-kit.d.ts +33 -0
- package/dist/types/editor/view.d.ts +5 -0
- package/dist/types/editor-bootstrap.d.ts +37 -0
- package/dist/types/exclusion-groups.d.ts +59 -0
- package/dist/types/exposure-signals.d.ts +7 -0
- package/dist/types/flags.d.ts +65 -0
- package/dist/types/goals.d.ts +16 -0
- package/dist/types/guards.d.ts +39 -0
- package/dist/types/handover.d.ts +10 -0
- package/dist/types/hash.d.ts +19 -0
- package/dist/types/heatmap.d.ts +8 -0
- package/dist/types/hideflicker.d.ts +15 -0
- package/dist/types/hydration.d.ts +11 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/js.d.ts +6 -0
- package/dist/types/module.d.ts +4 -0
- package/dist/types/money.d.ts +14 -0
- package/dist/types/mutate.d.ts +34 -0
- package/dist/types/observer-mute.d.ts +3 -0
- package/dist/types/pii-keys.d.ts +1 -0
- package/dist/types/prerequisites.d.ts +12 -0
- package/dist/types/qa-assistant-state.d.ts +68 -0
- package/dist/types/qa-assistant-view.d.ts +6 -0
- package/dist/types/qa-assistant.d.ts +9 -0
- package/dist/types/qa-toolbar.d.ts +19 -0
- package/dist/types/react/index.d.ts +84 -0
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/router.d.ts +9 -0
- package/dist/types/runtime-tools.d.ts +50 -0
- package/dist/types/runtime.d.ts +22 -0
- package/dist/types/safe-regex.d.ts +6 -0
- package/dist/types/sanitize.d.ts +5 -0
- package/dist/types/semantic.d.ts +7 -0
- package/dist/types/shopify-cart-events.d.ts +7 -0
- package/dist/types/shopify-cart.d.ts +62 -0
- package/dist/types/signals.d.ts +31 -0
- package/dist/types/surface-signals.d.ts +6 -0
- package/dist/types/targeting-diagnostics.d.ts +5 -0
- package/dist/types/targeting.d.ts +50 -0
- package/dist/types/test-fixtures/commerce.d.ts +12 -0
- package/dist/types/track.d.ts +79 -0
- package/dist/types/types.d.ts +859 -0
- package/dist/types/wait.d.ts +7 -0
- package/package.json +74 -0
- package/src/analytics.ts +146 -0
- package/src/antiflicker.test.ts +127 -0
- package/src/antiflicker.ts +147 -0
- package/src/assignment-store.test.ts +171 -0
- package/src/assignment-store.ts +208 -0
- package/src/audit.test.ts +17 -0
- package/src/audit.ts +362 -0
- package/src/bucket.test.ts +65 -0
- package/src/bucket.ts +18 -0
- package/src/bucketing-cleanup.test.ts +30 -0
- package/src/bucketing-cleanup.ts +72 -0
- package/src/bucketing-parity.test.ts +41 -0
- package/src/commerce-analytics-lifecycle.test.ts +1416 -0
- package/src/commerce-config-lifecycle.test.ts +274 -0
- package/src/commerce-epoch-lifecycle.test.ts +62 -0
- package/src/commerce-gift.test.ts +1293 -0
- package/src/commerce-gift.ts +979 -0
- package/src/commerce-offers-display.test.ts +154 -0
- package/src/commerce-price.test.ts +1145 -0
- package/src/commerce-price.ts +941 -0
- package/src/commerce-revocation-lifecycle.test.ts +78 -0
- package/src/commerce-shipping.test.ts +630 -0
- package/src/commerce-shipping.ts +447 -0
- package/src/config-fetch.test.ts +325 -0
- package/src/css.ts +54 -0
- package/src/cwv.test.ts +95 -0
- package/src/cwv.ts +129 -0
- package/src/data-layer-normalize.ts +21 -0
- package/src/debug.test.ts +83 -0
- package/src/debug.ts +138 -0
- package/src/define.test.ts +327 -0
- package/src/define.ts +208 -0
- package/src/devtools.ts +582 -0
- package/src/dom-trigger.test.ts +70 -0
- package/src/dom-trigger.ts +55 -0
- package/src/editor/api.test.ts +154 -0
- package/src/editor/api.ts +185 -0
- package/src/editor/artifacts.test.ts +57 -0
- package/src/editor/artifacts.ts +115 -0
- package/src/editor/command-runtime.test.ts +225 -0
- package/src/editor/command-runtime.ts +572 -0
- package/src/editor/controller.test.ts +371 -0
- package/src/editor/controller.ts +596 -0
- package/src/editor/device-preview.test.ts +60 -0
- package/src/editor/device-preview.ts +120 -0
- package/src/editor/index.test.ts +161 -0
- package/src/editor/index.ts +169 -0
- package/src/editor/operator.test.ts +148 -0
- package/src/editor/operator.ts +256 -0
- package/src/editor/picker.test.ts +58 -0
- package/src/editor/picker.ts +152 -0
- package/src/editor/selector.test.ts +44 -0
- package/src/editor/selector.ts +110 -0
- package/src/editor/types.ts +92 -0
- package/src/editor/view-kit.ts +162 -0
- package/src/editor/view.test.ts +535 -0
- package/src/editor/view.ts +1395 -0
- package/src/editor-bootstrap.test.ts +116 -0
- package/src/editor-bootstrap.ts +213 -0
- package/src/event-schema-contract.test.ts +35 -0
- package/src/exclusion-groups.test.ts +383 -0
- package/src/exclusion-groups.ts +270 -0
- package/src/exposure-signals.test.ts +182 -0
- package/src/exposure-signals.ts +110 -0
- package/src/flags.test.ts +322 -0
- package/src/flags.ts +201 -0
- package/src/goals.test.ts +42 -0
- package/src/goals.ts +200 -0
- package/src/guards.test.ts +249 -0
- package/src/guards.ts +250 -0
- package/src/handover-cache-order.test.ts +32 -0
- package/src/handover.test.ts +349 -0
- package/src/handover.ts +309 -0
- package/src/hash.ts +214 -0
- package/src/heatmap.test.ts +305 -0
- package/src/heatmap.ts +373 -0
- package/src/hideflicker.ts +21 -0
- package/src/hydration.test.ts +252 -0
- package/src/hydration.ts +203 -0
- package/src/index.ts +7513 -0
- package/src/init-empty.test.ts +5572 -0
- package/src/js.test.ts +26 -0
- package/src/js.ts +61 -0
- package/src/module.ts +50 -0
- package/src/money.test.ts +35 -0
- package/src/money.ts +53 -0
- package/src/mutate.test.ts +304 -0
- package/src/mutate.ts +469 -0
- package/src/observer-mute.test.ts +88 -0
- package/src/observer-mute.ts +45 -0
- package/src/page-trigger.test.ts +84 -0
- package/src/personalization.test.ts +68 -0
- package/src/pii-keys.ts +17 -0
- package/src/prerequisites.test.ts +39 -0
- package/src/prerequisites.ts +94 -0
- package/src/qa-assistant-state.test.ts +471 -0
- package/src/qa-assistant-state.ts +505 -0
- package/src/qa-assistant-view.test.ts +185 -0
- package/src/qa-assistant-view.ts +768 -0
- package/src/qa-assistant.ts +40 -0
- package/src/qa-toolbar.ts +444 -0
- package/src/react/index.test.tsx +177 -0
- package/src/react/index.tsx +252 -0
- package/src/reassert-sentinel.test.ts +314 -0
- package/src/reassert-sentinel.ts +191 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +92 -0
- package/src/runtime-tools.test.ts +112 -0
- package/src/runtime-tools.ts +403 -0
- package/src/runtime.test.ts +217 -0
- package/src/runtime.ts +268 -0
- package/src/safe-regex.ts +35 -0
- package/src/sanitize.ts +34 -0
- package/src/semantic.ts +245 -0
- package/src/shopify-cart-events.test.ts +76 -0
- package/src/shopify-cart-events.ts +115 -0
- package/src/shopify-cart.test.ts +2823 -0
- package/src/shopify-cart.ts +1256 -0
- package/src/signals.ts +226 -0
- package/src/surface-signals.test.ts +83 -0
- package/src/surface-signals.ts +165 -0
- package/src/targeting-diagnostics.ts +560 -0
- package/src/targeting.detailed.test.ts +443 -0
- package/src/targeting.test.ts +1054 -0
- package/src/targeting.ts +1024 -0
- package/src/test-fixtures/commerce.ts +65 -0
- package/src/track.test.ts +817 -0
- package/src/track.ts +519 -0
- package/src/types.ts +991 -0
- package/src/wait.test.ts +96 -0
- package/src/wait.ts +238 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { Experiment, Variation } from "./types";
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = "drip_assignments_v1"; // gitleaks:allow — public storage key name, not a credential
|
|
4
|
+
const COOKIE_NAME = "drip_assignments_v1";
|
|
5
|
+
const MAX_ENTRIES = 80;
|
|
6
|
+
const MAX_COOKIE_ENTRIES = 20;
|
|
7
|
+
const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
|
8
|
+
|
|
9
|
+
export type PersistedAssignmentRecord = {
|
|
10
|
+
variationId: string;
|
|
11
|
+
epoch: number;
|
|
12
|
+
assignedAt: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type CompactRecord = [string, number, number];
|
|
16
|
+
type CompactStore = Record<string, CompactRecord>;
|
|
17
|
+
|
|
18
|
+
function assignmentEpoch(experiment: Experiment): number {
|
|
19
|
+
return Number.isFinite(experiment.assignmentEpoch)
|
|
20
|
+
? Math.max(1, Math.floor(experiment.assignmentEpoch as number))
|
|
21
|
+
: 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readLocalStorage(): string | null {
|
|
25
|
+
try {
|
|
26
|
+
return globalThis.localStorage?.getItem(STORAGE_KEY) ?? null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeLocalStorage(value: string): void {
|
|
33
|
+
try {
|
|
34
|
+
globalThis.localStorage?.setItem(STORAGE_KEY, value);
|
|
35
|
+
} catch {
|
|
36
|
+
// Ignore storage failures; cookie fallback is best effort.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readCookie(): string | null {
|
|
41
|
+
try {
|
|
42
|
+
const cookie = globalThis.document?.cookie ?? "";
|
|
43
|
+
const match = cookie.match(new RegExp("(^| )" + COOKIE_NAME + "=([^;]+)"));
|
|
44
|
+
return match ? decodeURIComponent(match[2]) : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeCookie(value: string): void {
|
|
51
|
+
try {
|
|
52
|
+
if (!globalThis.document) return;
|
|
53
|
+
globalThis.document.cookie =
|
|
54
|
+
COOKIE_NAME +
|
|
55
|
+
"=" +
|
|
56
|
+
encodeURIComponent(value) +
|
|
57
|
+
"; Max-Age=" +
|
|
58
|
+
COOKIE_MAX_AGE_SECONDS +
|
|
59
|
+
"; Path=/; SameSite=Lax";
|
|
60
|
+
} catch {
|
|
61
|
+
// Ignore cookie failures.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function clearCookie(): void {
|
|
66
|
+
try {
|
|
67
|
+
if (!globalThis.document) return;
|
|
68
|
+
globalThis.document.cookie =
|
|
69
|
+
COOKIE_NAME + "=; Max-Age=0; Path=/; SameSite=Lax";
|
|
70
|
+
} catch {
|
|
71
|
+
// Ignore cookie failures.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseStore(raw: string | null): CompactStore {
|
|
76
|
+
if (!raw) return {};
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
79
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
80
|
+
const out: CompactStore = {};
|
|
81
|
+
for (const [experimentId, value] of Object.entries(parsed)) {
|
|
82
|
+
if (
|
|
83
|
+
typeof experimentId !== "string" ||
|
|
84
|
+
!Array.isArray(value) ||
|
|
85
|
+
typeof value[0] !== "string" ||
|
|
86
|
+
typeof value[1] !== "number" ||
|
|
87
|
+
typeof value[2] !== "number"
|
|
88
|
+
) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
out[experimentId] = [value[0], value[1], value[2]];
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
} catch {
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function serializeStore(store: CompactStore, maxEntries = MAX_ENTRIES): string {
|
|
100
|
+
const entries = Object.entries(store)
|
|
101
|
+
.sort(([, a], [, b]) => b[2] - a[2])
|
|
102
|
+
.slice(0, maxEntries);
|
|
103
|
+
// Object.fromEntries is ES2019; the SDK lib target predates it.
|
|
104
|
+
const trimmed: CompactStore = {};
|
|
105
|
+
for (const [experimentId, record] of entries) {
|
|
106
|
+
trimmed[experimentId] = record;
|
|
107
|
+
}
|
|
108
|
+
return JSON.stringify(trimmed);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function readPersistedAssignments(): Record<string, PersistedAssignmentRecord> {
|
|
112
|
+
const compact = {
|
|
113
|
+
...parseStore(readCookie()),
|
|
114
|
+
...parseStore(readLocalStorage()),
|
|
115
|
+
};
|
|
116
|
+
const out: Record<string, PersistedAssignmentRecord> = {};
|
|
117
|
+
for (const [experimentId, [variationId, epoch, assignedAt]] of Object.entries(compact)) {
|
|
118
|
+
out[experimentId] = { variationId, epoch, assignedAt };
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function writePersistedAssignments(
|
|
124
|
+
records: Record<string, PersistedAssignmentRecord>,
|
|
125
|
+
): void {
|
|
126
|
+
const compact: CompactStore = {};
|
|
127
|
+
for (const [experimentId, record] of Object.entries(records)) {
|
|
128
|
+
compact[experimentId] = [record.variationId, record.epoch, record.assignedAt];
|
|
129
|
+
}
|
|
130
|
+
writeLocalStorage(serializeStore(compact, MAX_ENTRIES));
|
|
131
|
+
writeCookie(serializeStore(compact, MAX_COOKIE_ENTRIES));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function clearPersistedAssignments(): void {
|
|
135
|
+
try {
|
|
136
|
+
globalThis.localStorage?.removeItem(STORAGE_KEY);
|
|
137
|
+
} catch {
|
|
138
|
+
// Ignore storage failures.
|
|
139
|
+
}
|
|
140
|
+
clearCookie();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function prunePersistedAssignments(
|
|
144
|
+
experiments: Experiment[],
|
|
145
|
+
records = readPersistedAssignments(),
|
|
146
|
+
): Record<string, PersistedAssignmentRecord> {
|
|
147
|
+
const allowed = new Set(experiments.map((experiment) => experiment.id));
|
|
148
|
+
let changed = false;
|
|
149
|
+
for (const experimentId of Object.keys(records)) {
|
|
150
|
+
if (!allowed.has(experimentId)) {
|
|
151
|
+
delete records[experimentId];
|
|
152
|
+
changed = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (changed) writePersistedAssignments(records);
|
|
156
|
+
return records;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function findPersistedVariation(
|
|
160
|
+
experiment: Experiment,
|
|
161
|
+
records = readPersistedAssignments(),
|
|
162
|
+
): { variation: Variation; index: number; record: PersistedAssignmentRecord } | null {
|
|
163
|
+
const record = records[experiment.id];
|
|
164
|
+
if (!record) return null;
|
|
165
|
+
if (record.epoch !== assignmentEpoch(experiment)) return null;
|
|
166
|
+
const index = experiment.variations.findIndex(
|
|
167
|
+
(variation) => variation.id === record.variationId,
|
|
168
|
+
);
|
|
169
|
+
if (index < 0) return null;
|
|
170
|
+
return { variation: experiment.variations[index], index, record };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function persistAssignmentForExperiment(
|
|
174
|
+
experiment: Experiment,
|
|
175
|
+
variationId: string,
|
|
176
|
+
assignedAt = Date.now(),
|
|
177
|
+
): PersistedAssignmentRecord {
|
|
178
|
+
const records = readPersistedAssignments();
|
|
179
|
+
const record = {
|
|
180
|
+
variationId,
|
|
181
|
+
epoch: assignmentEpoch(experiment),
|
|
182
|
+
assignedAt,
|
|
183
|
+
};
|
|
184
|
+
records[experiment.id] = record;
|
|
185
|
+
writePersistedAssignments(records);
|
|
186
|
+
return record;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function removePersistedAssignmentsForExperiments(
|
|
190
|
+
experimentIds: Iterable<string>,
|
|
191
|
+
): void {
|
|
192
|
+
const records = readPersistedAssignments();
|
|
193
|
+
let changed = false;
|
|
194
|
+
for (const experimentId of experimentIds) {
|
|
195
|
+
if (!(experimentId in records)) continue;
|
|
196
|
+
delete records[experimentId];
|
|
197
|
+
changed = true;
|
|
198
|
+
}
|
|
199
|
+
if (changed) writePersistedAssignments(records);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const __assignmentStoreInternals = {
|
|
203
|
+
STORAGE_KEY,
|
|
204
|
+
COOKIE_NAME,
|
|
205
|
+
assignmentEpoch,
|
|
206
|
+
parseStore,
|
|
207
|
+
serializeStore,
|
|
208
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isApexScriptSrc } from "./audit";
|
|
3
|
+
|
|
4
|
+
describe("isApexScriptSrc", () => {
|
|
5
|
+
it("matches Apex SDK script URLs", () => {
|
|
6
|
+
expect(isApexScriptSrc("https://sdk.drip-apex.com/drip.js")).toBe(true);
|
|
7
|
+
// Keep recognizing the retired Pages hostname so audits can detect legacy installs.
|
|
8
|
+
expect(isApexScriptSrc("https://drip-sdk.pages.dev/drip.js")).toBe(true);
|
|
9
|
+
expect(isApexScriptSrc("https://app.drip-apex.com/drip.js")).toBe(true);
|
|
10
|
+
expect(isApexScriptSrc("https://cdn.example.com/apex-sdk.min.js")).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("does not treat ordinary same-site scripts on drip domains as Apex installs", () => {
|
|
14
|
+
expect(isApexScriptSrc("https://dripagency.de/_next/static/chunks/app.js")).toBe(false);
|
|
15
|
+
expect(isApexScriptSrc("https://dripagency.de/assets/navigation.js")).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
});
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { InitConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_AUDIT_ENDPOINT = "https://app.drip-apex.com/api/shop-audits/collect";
|
|
4
|
+
const MAX_TEXT_SAMPLE = 3000;
|
|
5
|
+
const MAX_ITEMS = 80;
|
|
6
|
+
|
|
7
|
+
type RuntimeAuditContext = {
|
|
8
|
+
configSource: string;
|
|
9
|
+
trackerStarted: boolean;
|
|
10
|
+
platform: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ResourceError = {
|
|
14
|
+
url: string;
|
|
15
|
+
tag: string;
|
|
16
|
+
type: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const resourceErrors: ResourceError[] = [];
|
|
20
|
+
let listenersInstalled = false;
|
|
21
|
+
let inMemorySentToken = "";
|
|
22
|
+
|
|
23
|
+
function installAuditListeners(): void {
|
|
24
|
+
if (listenersInstalled || typeof window === "undefined") return;
|
|
25
|
+
listenersInstalled = true;
|
|
26
|
+
|
|
27
|
+
window.addEventListener("error", (event) => {
|
|
28
|
+
const target = event.target;
|
|
29
|
+
if (!(target instanceof HTMLElement)) return;
|
|
30
|
+
const url =
|
|
31
|
+
target instanceof HTMLScriptElement ||
|
|
32
|
+
target instanceof HTMLImageElement
|
|
33
|
+
? target.src
|
|
34
|
+
: target instanceof HTMLLinkElement
|
|
35
|
+
? target.href
|
|
36
|
+
: "";
|
|
37
|
+
if (!url) return;
|
|
38
|
+
resourceErrors.push({
|
|
39
|
+
url: redactUrl(url),
|
|
40
|
+
tag: target.tagName.toLowerCase(),
|
|
41
|
+
type: "resource_error",
|
|
42
|
+
});
|
|
43
|
+
if (resourceErrors.length > 30) resourceErrors.shift();
|
|
44
|
+
}, true);
|
|
45
|
+
|
|
46
|
+
window.addEventListener("unhandledrejection", () => {
|
|
47
|
+
resourceErrors.push({
|
|
48
|
+
url: redactUrl(location.href),
|
|
49
|
+
tag: "window",
|
|
50
|
+
type: "unhandled_rejection",
|
|
51
|
+
});
|
|
52
|
+
if (resourceErrors.length > 30) resourceErrors.shift();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
installAuditListeners();
|
|
57
|
+
|
|
58
|
+
function queryToken(): string | null {
|
|
59
|
+
try {
|
|
60
|
+
return new URLSearchParams(location.search).get("apex_audit");
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function oncePerPage(token: string): boolean {
|
|
67
|
+
const key = `drip_audit_sent:${token}:${location.origin}${location.pathname}`;
|
|
68
|
+
if (inMemorySentToken === key) return false;
|
|
69
|
+
inMemorySentToken = key;
|
|
70
|
+
try {
|
|
71
|
+
if (sessionStorage.getItem(key) === "1") return false;
|
|
72
|
+
sessionStorage.setItem(key, "1");
|
|
73
|
+
} catch {
|
|
74
|
+
// Storage may be blocked; the in-memory guard still prevents immediate duplicates.
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function schedule(callback: () => void): void {
|
|
80
|
+
const win = window as Window & {
|
|
81
|
+
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
|
|
82
|
+
};
|
|
83
|
+
if (typeof win.requestIdleCallback === "function") {
|
|
84
|
+
win.requestIdleCallback(callback, { timeout: 1500 });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
window.setTimeout(callback, document.readyState === "complete" ? 400 : 1200);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function textOf(el: Element | null): string {
|
|
91
|
+
return (el?.textContent || "").replace(/\s+/g, " ").trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function collectText(selector: string, limit: number): string[] {
|
|
95
|
+
return Array.from(document.querySelectorAll(selector))
|
|
96
|
+
.map(textOf)
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.slice(0, limit);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function redactUrl(raw: string): string {
|
|
102
|
+
try {
|
|
103
|
+
const url = new URL(raw, location.href);
|
|
104
|
+
return `${url.origin}${url.pathname}`;
|
|
105
|
+
} catch {
|
|
106
|
+
return raw.split("?")[0]?.slice(0, 180) || "";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function rgbToHex(value: string): string | null {
|
|
111
|
+
const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
|
112
|
+
if (!match) return null;
|
|
113
|
+
const parts = match.slice(1, 4).map((part) => {
|
|
114
|
+
const n = Math.max(0, Math.min(255, Number.parseInt(part, 10) || 0));
|
|
115
|
+
return n.toString(16).padStart(2, "0");
|
|
116
|
+
});
|
|
117
|
+
const hex = `#${parts.join("")}`;
|
|
118
|
+
return hex === "#000000" || hex === "#ffffff" ? null : hex;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function unique(values: string[], limit: number): string[] {
|
|
122
|
+
return Array.from(new Set(values.filter(Boolean))).slice(0, limit);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function collectStyles() {
|
|
126
|
+
const colors: string[] = [];
|
|
127
|
+
const fonts: string[] = [];
|
|
128
|
+
const buttonStyles: string[] = [];
|
|
129
|
+
const elements = Array.from(document.querySelectorAll("body *")).slice(0, 220);
|
|
130
|
+
|
|
131
|
+
for (const el of elements) {
|
|
132
|
+
const style = getComputedStyle(el);
|
|
133
|
+
const color = rgbToHex(style.color);
|
|
134
|
+
const bg = rgbToHex(style.backgroundColor);
|
|
135
|
+
const border = rgbToHex(style.borderColor);
|
|
136
|
+
if (color) colors.push(color);
|
|
137
|
+
if (bg) colors.push(bg);
|
|
138
|
+
if (border) colors.push(border);
|
|
139
|
+
if (style.fontFamily) fonts.push(style.fontFamily.split(",")[0]?.replace(/"/g, "").trim() || "");
|
|
140
|
+
|
|
141
|
+
const isButton =
|
|
142
|
+
el.tagName === "BUTTON" ||
|
|
143
|
+
el.getAttribute("role") === "button" ||
|
|
144
|
+
(el instanceof HTMLAnchorElement && /button|btn|cta/i.test(el.className));
|
|
145
|
+
if (isButton) {
|
|
146
|
+
buttonStyles.push([
|
|
147
|
+
`color ${color || style.color}`,
|
|
148
|
+
`background ${bg || style.backgroundColor}`,
|
|
149
|
+
`radius ${style.borderRadius}`,
|
|
150
|
+
].join(", "));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
colors: unique(colors, 12),
|
|
156
|
+
fonts: unique(fonts, 8),
|
|
157
|
+
buttons: unique(buttonStyles, 8),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function hasStorage(storage: Storage | undefined): boolean {
|
|
162
|
+
if (!storage) return false;
|
|
163
|
+
const key = "__drip_audit_test__";
|
|
164
|
+
try {
|
|
165
|
+
storage.setItem(key, "1");
|
|
166
|
+
storage.removeItem(key);
|
|
167
|
+
return true;
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function storageAvailable(kind: "localStorage" | "sessionStorage"): boolean {
|
|
174
|
+
try {
|
|
175
|
+
return hasStorage(window[kind]);
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function detectPlatform(): string {
|
|
182
|
+
const win = window as Window & { Shopify?: unknown; WooCommerce?: unknown };
|
|
183
|
+
const html = document.documentElement.innerHTML.slice(0, 120000);
|
|
184
|
+
if (win.Shopify || /cdn\.shopify\.com|Shopify\.theme/i.test(html)) return "shopify";
|
|
185
|
+
if (win.WooCommerce || /wp-content|woocommerce/i.test(html)) return "woocommerce";
|
|
186
|
+
if (/__NEXT_DATA__|next\/static/i.test(html)) return "nextjs";
|
|
187
|
+
return "unknown";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function detectConsentBanner(): boolean {
|
|
191
|
+
const selectors = [
|
|
192
|
+
"[id*='consent' i]",
|
|
193
|
+
"[class*='consent' i]",
|
|
194
|
+
"[id*='cookie' i]",
|
|
195
|
+
"[class*='cookie' i]",
|
|
196
|
+
"[aria-label*='cookie' i]",
|
|
197
|
+
];
|
|
198
|
+
if (selectors.some((selector) => document.querySelector(selector))) return true;
|
|
199
|
+
return /cookie|consent|gdpr|privacy preferences/i.test(document.body.innerText.slice(0, 6000));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function collectSchemaTypes(): string[] {
|
|
203
|
+
const types: string[] = [];
|
|
204
|
+
const scripts = Array.from(document.querySelectorAll("script[type='application/ld+json']"));
|
|
205
|
+
scripts.forEach((script) => {
|
|
206
|
+
try {
|
|
207
|
+
const parsed = JSON.parse(script.textContent || "{}");
|
|
208
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
209
|
+
items.forEach((item) => {
|
|
210
|
+
const record = item && typeof item === "object" ? item as Record<string, unknown> : {};
|
|
211
|
+
const type = String(record["@type"] || "");
|
|
212
|
+
if (type) types.push(type);
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
// Ignore malformed schema blocks.
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
return unique(types, 12);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function isApexScriptSrc(raw: string): boolean {
|
|
222
|
+
if (!raw) return false;
|
|
223
|
+
try {
|
|
224
|
+
const url = new URL(raw, location.href);
|
|
225
|
+
const host = url.hostname.toLowerCase();
|
|
226
|
+
const path = url.pathname.toLowerCase();
|
|
227
|
+
return (
|
|
228
|
+
host.includes("drip-sdk") ||
|
|
229
|
+
host.includes("drip-apex") ||
|
|
230
|
+
/(^|\/)(?:drip|apex)(?:[-.]sdk)?(?:\.min)?\.js$/.test(path) ||
|
|
231
|
+
/\/(?:drip|apex)-sdk(?:\.min)?\.js$/.test(path)
|
|
232
|
+
);
|
|
233
|
+
} catch {
|
|
234
|
+
return /(?:^|\/)(?:drip|apex)(?:[-.]sdk)?(?:\.min)?\.js(?:$|\?)/i.test(raw);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function collectNetwork(config: InitConfig) {
|
|
239
|
+
const entries = typeof performance !== "undefined"
|
|
240
|
+
? performance.getEntriesByType("resource") as PerformanceResourceTiming[]
|
|
241
|
+
: [];
|
|
242
|
+
const resources = entries.slice(0, MAX_ITEMS).map((entry) => ({
|
|
243
|
+
url: redactUrl(entry.name),
|
|
244
|
+
type: entry.initiatorType,
|
|
245
|
+
durationMs: Math.round(entry.duration),
|
|
246
|
+
transferSize: Math.max(0, Math.round(entry.transferSize || 0)),
|
|
247
|
+
}));
|
|
248
|
+
const currentHost = location.hostname;
|
|
249
|
+
const thirdParties: string[] = [];
|
|
250
|
+
resources.forEach((resource) => {
|
|
251
|
+
try {
|
|
252
|
+
const host = new URL(resource.url).hostname;
|
|
253
|
+
if (host && host !== currentHost) thirdParties.push(host);
|
|
254
|
+
} catch {
|
|
255
|
+
// Ignore invalid resource URLs.
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
const notableThirdParties = unique(thirdParties, 18);
|
|
259
|
+
const apexRequests = resources.filter((resource) =>
|
|
260
|
+
/drip-apex|drip-sdk|drip\.js|\/events$|\/config$/i.test(resource.url),
|
|
261
|
+
).slice(0, 20);
|
|
262
|
+
const configuredEndpoint = config.endpoint ? redactUrl(config.endpoint) : "";
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
configuredEndpoint,
|
|
266
|
+
apexRequests,
|
|
267
|
+
notableThirdParties,
|
|
268
|
+
resources,
|
|
269
|
+
failedResources: resourceErrors.slice(0, 20),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function collectSnapshot(config: InitConfig, runtime: RuntimeAuditContext) {
|
|
274
|
+
const headings = collectText("h1,h2,h3", 16);
|
|
275
|
+
const buttons = collectText("button,a[role='button'],input[type='submit'],a[href*='/cart']", 16);
|
|
276
|
+
const imageAlts = unique(Array.from(document.images)
|
|
277
|
+
.map((img) => img.alt.trim())
|
|
278
|
+
.filter(Boolean), 12);
|
|
279
|
+
const schemaTypes = collectSchemaTypes();
|
|
280
|
+
const textSample = document.body.innerText.replace(/\s+/g, " ").trim().slice(0, MAX_TEXT_SAMPLE);
|
|
281
|
+
const offerPhrases = unique(
|
|
282
|
+
(textSample.match(/\b(?:free shipping|discount|sale|guarantee|subscribe|bundle|save \d+%|limited time)\b/gi) || [])
|
|
283
|
+
.map((item) => item.toLowerCase()),
|
|
284
|
+
10,
|
|
285
|
+
);
|
|
286
|
+
const apexScriptCount = Array.from(document.scripts)
|
|
287
|
+
.filter((script) => isApexScriptSrc(script.src || ""))
|
|
288
|
+
.length;
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
page: {
|
|
292
|
+
url: redactUrl(location.href),
|
|
293
|
+
title: document.title,
|
|
294
|
+
referrer: document.referrer ? redactUrl(document.referrer) : "",
|
|
295
|
+
language: document.documentElement.lang || navigator.language || "",
|
|
296
|
+
viewport: {
|
|
297
|
+
width: window.innerWidth,
|
|
298
|
+
height: window.innerHeight,
|
|
299
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
sdk: {
|
|
303
|
+
installed: true,
|
|
304
|
+
version: "0.1.0",
|
|
305
|
+
shopId: config.shopId || "",
|
|
306
|
+
configSource: runtime.configSource,
|
|
307
|
+
trackerStarted: runtime.trackerStarted,
|
|
308
|
+
eventsEndpointReachable: runtime.trackerStarted || Boolean(config.endpoint),
|
|
309
|
+
duplicateInstalls: apexScriptCount > 1,
|
|
310
|
+
scriptCount: apexScriptCount,
|
|
311
|
+
},
|
|
312
|
+
site: {
|
|
313
|
+
platform: runtime.platform || detectPlatform(),
|
|
314
|
+
spaDetected: Boolean(document.querySelector("[data-reactroot], [ng-version], [data-v-app], [data-sveltekit], #__next")),
|
|
315
|
+
consentBannerDetected: detectConsentBanner(),
|
|
316
|
+
cartDetected: Boolean(document.querySelector("a[href*='/cart'], form[action*='/cart']")),
|
|
317
|
+
productJsonLdDetected: schemaTypes.some((type) => /product/i.test(type)),
|
|
318
|
+
schemaTypes,
|
|
319
|
+
},
|
|
320
|
+
dom: {
|
|
321
|
+
headings,
|
|
322
|
+
buttons,
|
|
323
|
+
imageAlts,
|
|
324
|
+
sections: collectText("nav,header,main,footer,section", 10),
|
|
325
|
+
offerPhrases,
|
|
326
|
+
textSample,
|
|
327
|
+
},
|
|
328
|
+
styles: collectStyles(),
|
|
329
|
+
browserCapabilities: {
|
|
330
|
+
cookiesAvailable: navigator.cookieEnabled,
|
|
331
|
+
localStorageAvailable: storageAvailable("localStorage"),
|
|
332
|
+
sessionStorageAvailable: storageAvailable("sessionStorage"),
|
|
333
|
+
cspWarnings: [],
|
|
334
|
+
blockedRequests: resourceErrors.map((item) => `${item.type}: ${item.url}`).slice(0, 12),
|
|
335
|
+
},
|
|
336
|
+
network: collectNetwork(config),
|
|
337
|
+
capturedAt: new Date().toISOString(),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function runAuditIfRequested(config: InitConfig, runtime: RuntimeAuditContext): void {
|
|
342
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
343
|
+
const token = queryToken();
|
|
344
|
+
if (!token || !oncePerPage(token)) return;
|
|
345
|
+
|
|
346
|
+
schedule(() => {
|
|
347
|
+
const endpoint = config.auditEndpoint || DEFAULT_AUDIT_ENDPOINT;
|
|
348
|
+
const body = JSON.stringify({
|
|
349
|
+
token,
|
|
350
|
+
snapshot: collectSnapshot(config, runtime),
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
fetch(endpoint, {
|
|
354
|
+
method: "POST",
|
|
355
|
+
headers: { "content-type": "application/json" },
|
|
356
|
+
body,
|
|
357
|
+
keepalive: body.length < 60000,
|
|
358
|
+
}).catch(() => {
|
|
359
|
+
// The audit should never affect storefront behavior.
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { chooseVariation, inTrafficAllocation } from "./bucket";
|
|
3
|
+
|
|
4
|
+
describe("bucket", () => {
|
|
5
|
+
it("respects traffic allocation bounds", () => {
|
|
6
|
+
expect(inTrafficAllocation(0.2, 0)).toBe(false);
|
|
7
|
+
expect(inTrafficAllocation(0.2, 1)).toBe(true);
|
|
8
|
+
expect(inTrafficAllocation(0.2, 0.5)).toBe(true);
|
|
9
|
+
expect(inTrafficAllocation(0.7, 0.5)).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("chooses weighted variations deterministically for the same hash value", () => {
|
|
13
|
+
expect(chooseVariation(0.1, [0.5, 0.5])).toBe(0);
|
|
14
|
+
expect(chooseVariation(0.9, [0.5, 0.5])).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("ignores non-positive weights", () => {
|
|
18
|
+
expect(chooseVariation(0.2, [0, 1])).toBe(1);
|
|
19
|
+
expect(chooseVariation(0.2, [-1, 1])).toBe(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("chooses from 3 variations with different weights", () => {
|
|
23
|
+
expect(chooseVariation(0.1, [0.33, 0.33, 0.34])).toBe(0);
|
|
24
|
+
expect(chooseVariation(0.5, [0.33, 0.33, 0.34])).toBe(1);
|
|
25
|
+
expect(chooseVariation(0.9, [0.33, 0.33, 0.34])).toBe(2);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns -1 for empty weights array", () => {
|
|
29
|
+
expect(chooseVariation(0.5, [])).toBe(-1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("handles boundary at exact cumulative weight", () => {
|
|
33
|
+
// hashValue exactly at 0.5 boundary with [0.5, 0.5]
|
|
34
|
+
// n = 0.5 * 1.0 = 0.5, acc after first = 0.5 → 0.5 < 0.5 is false
|
|
35
|
+
// acc after second = 1.0 → 0.5 < 1.0 is true → index 1
|
|
36
|
+
expect(chooseVariation(0.5, [0.5, 0.5])).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("always rejects when allocation is 0", () => {
|
|
40
|
+
expect(inTrafficAllocation(0.0, 0)).toBe(false);
|
|
41
|
+
expect(inTrafficAllocation(0.5, 0)).toBe(false);
|
|
42
|
+
expect(inTrafficAllocation(0.99, 0)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("always accepts when allocation is 1", () => {
|
|
46
|
+
expect(inTrafficAllocation(0.0, 1)).toBe(true);
|
|
47
|
+
expect(inTrafficAllocation(0.5, 1)).toBe(true);
|
|
48
|
+
expect(inTrafficAllocation(0.99, 1)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("handles boundary allocations", () => {
|
|
52
|
+
expect(inTrafficAllocation(0.998, 0.999)).toBe(true);
|
|
53
|
+
expect(inTrafficAllocation(0.002, 0.001)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("handles mixed positive/zero/negative weights", () => {
|
|
57
|
+
// [0, 0.5, -1, 0.5] → effective positives: [0, 0.5, 0, 0.5], total = 1.0
|
|
58
|
+
// hashValue 0.1: n = 0.1, skip w[0]=0, acc += 0.5 = 0.5, 0.1 < 0.5 → index 1
|
|
59
|
+
expect(chooseVariation(0.1, [0, 0.5, -1, 0.5])).toBe(1);
|
|
60
|
+
// hashValue 0.8: n = 0.8, skip w[0]=0, acc += 0.5 = 0.5 (0.8 < 0.5 no),
|
|
61
|
+
// skip w[2]=-1, acc += 0.5 = 1.0 (0.8 < 1.0 yes) → index 3
|
|
62
|
+
expect(chooseVariation(0.8, [0, 0.5, -1, 0.5])).toBe(3);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
package/src/bucket.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function inTrafficAllocation(hashValue: number, allocation = 1): boolean {
|
|
2
|
+
if (allocation <= 0) return false;
|
|
3
|
+
if (allocation >= 1) return true;
|
|
4
|
+
return hashValue < allocation;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function chooseVariation(hashValue: number, weights: number[]): number {
|
|
8
|
+
if (!weights.length) return -1;
|
|
9
|
+
const total = weights.reduce((sum, w) => sum + (w > 0 ? w : 0), 0);
|
|
10
|
+
if (total <= 0) return 0;
|
|
11
|
+
const n = hashValue * total;
|
|
12
|
+
let acc = 0;
|
|
13
|
+
for (let i = 0; i < weights.length; i++) {
|
|
14
|
+
acc += weights[i] > 0 ? weights[i] : 0;
|
|
15
|
+
if (n < acc) return i;
|
|
16
|
+
}
|
|
17
|
+
return weights.length - 1;
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { cleanupBucketingStorage } from "./bucketing-cleanup";
|
|
3
|
+
|
|
4
|
+
describe("bucketing cleanup", () => {
|
|
5
|
+
const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
if (originalLocalStorage) {
|
|
9
|
+
Object.defineProperty(globalThis, "localStorage", originalLocalStorage);
|
|
10
|
+
} else {
|
|
11
|
+
delete (globalThis as Record<string, unknown>).localStorage;
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("ignores browsers that expose blocked localStorage accessors", () => {
|
|
16
|
+
Object.defineProperty(globalThis, "localStorage", {
|
|
17
|
+
configurable: true,
|
|
18
|
+
get() {
|
|
19
|
+
throw new DOMException("blocked", "SecurityError");
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(() => {
|
|
24
|
+
cleanupBucketingStorage([], {
|
|
25
|
+
shopId: "shop_blocked_storage",
|
|
26
|
+
retentionDays: 180,
|
|
27
|
+
});
|
|
28
|
+
}).not.toThrow();
|
|
29
|
+
});
|
|
30
|
+
});
|