@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,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { __test, configureDebug, isDebugEnabled } from "./debug";
|
|
3
|
+
|
|
4
|
+
const originalLocation = Object.getOwnPropertyDescriptor(globalThis, "location");
|
|
5
|
+
const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
|
6
|
+
|
|
7
|
+
function setSearch(search: string) {
|
|
8
|
+
Object.defineProperty(globalThis, "location", {
|
|
9
|
+
configurable: true,
|
|
10
|
+
value: { search },
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function installLocalStorage() {
|
|
15
|
+
const values = new Map<string, string>();
|
|
16
|
+
Object.defineProperty(globalThis, "localStorage", {
|
|
17
|
+
configurable: true,
|
|
18
|
+
value: {
|
|
19
|
+
getItem: (key: string) => values.get(key) ?? null,
|
|
20
|
+
setItem: (key: string, value: string) => {
|
|
21
|
+
values.set(key, value);
|
|
22
|
+
},
|
|
23
|
+
removeItem: (key: string) => {
|
|
24
|
+
values.delete(key);
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.useRealTimers();
|
|
32
|
+
if (originalLocation) {
|
|
33
|
+
Object.defineProperty(globalThis, "location", originalLocation);
|
|
34
|
+
} else {
|
|
35
|
+
delete (globalThis as any).location;
|
|
36
|
+
}
|
|
37
|
+
if (originalLocalStorage) {
|
|
38
|
+
Object.defineProperty(globalThis, "localStorage", originalLocalStorage);
|
|
39
|
+
} else {
|
|
40
|
+
delete (globalThis as any).localStorage;
|
|
41
|
+
}
|
|
42
|
+
configureDebug({ enabled: false, restrictMode: "off" });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("debug console activation", () => {
|
|
46
|
+
it("enables query-param debug before the backend expiry", () => {
|
|
47
|
+
vi.setSystemTime(new Date("2026-06-01T10:00:00.000Z"));
|
|
48
|
+
setSearch("?drip_debug=1");
|
|
49
|
+
installLocalStorage();
|
|
50
|
+
|
|
51
|
+
expect(
|
|
52
|
+
configureDebug({
|
|
53
|
+
enabled: true,
|
|
54
|
+
restrictMode: "query_param",
|
|
55
|
+
queryParam: "drip_debug",
|
|
56
|
+
expiresAt: "2026-06-02T10:00:00.000Z",
|
|
57
|
+
}),
|
|
58
|
+
).toBe(true);
|
|
59
|
+
expect(isDebugEnabled()).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("disables query-param debug after the backend expiry", () => {
|
|
63
|
+
vi.setSystemTime(new Date("2026-06-02T10:00:01.000Z"));
|
|
64
|
+
setSearch("?drip_debug=1");
|
|
65
|
+
installLocalStorage();
|
|
66
|
+
|
|
67
|
+
expect(
|
|
68
|
+
configureDebug({
|
|
69
|
+
enabled: true,
|
|
70
|
+
restrictMode: "query_param",
|
|
71
|
+
queryParam: "drip_debug",
|
|
72
|
+
expiresAt: "2026-06-02T10:00:00.000Z",
|
|
73
|
+
}),
|
|
74
|
+
).toBe(false);
|
|
75
|
+
expect(isDebugEnabled()).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("exposes expiry parsing for unit coverage", () => {
|
|
79
|
+
vi.setSystemTime(new Date("2026-06-01T10:00:00.000Z"));
|
|
80
|
+
expect(__test.isExpired("2026-06-01T09:59:59.000Z")).toBe(true);
|
|
81
|
+
expect(__test.isExpired("2026-06-01T10:00:01.000Z")).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug console for Drip SDK.
|
|
3
|
+
*
|
|
4
|
+
* Project-configured debug console. By default debug is off; the snippet can
|
|
5
|
+
* enable it for a specific request, query parameter, or user id.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DebugSettings } from "./types";
|
|
9
|
+
|
|
10
|
+
let enabled = false;
|
|
11
|
+
let configured: DebugSettings = { enabled: false, restrictMode: "off" };
|
|
12
|
+
|
|
13
|
+
function getQueryValue(param: string): string | null {
|
|
14
|
+
try {
|
|
15
|
+
return new URLSearchParams(location.search).get(param);
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tokenMatches(value: string | null, token?: string): boolean {
|
|
22
|
+
if (!value) return false;
|
|
23
|
+
if (!token) return value === "1" || value === "true";
|
|
24
|
+
return value === token;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isExpired(expiresAt?: string | null): boolean {
|
|
28
|
+
if (!expiresAt) return false;
|
|
29
|
+
const timestamp = Date.parse(expiresAt);
|
|
30
|
+
return Number.isFinite(timestamp) && timestamp <= Date.now();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function configureDebug(
|
|
34
|
+
settings?: DebugSettings,
|
|
35
|
+
userId?: string | null,
|
|
36
|
+
options?: { storageAllowed?: boolean },
|
|
37
|
+
): boolean {
|
|
38
|
+
const storageAllowed = options?.storageAllowed !== false;
|
|
39
|
+
configured = storageAllowed
|
|
40
|
+
? (settings ?? { enabled: false, restrictMode: "off" })
|
|
41
|
+
: { ...(settings ?? { enabled: false, restrictMode: "off" }), persist: false };
|
|
42
|
+
enabled = false;
|
|
43
|
+
if (!configured.enabled || configured.restrictMode === "off" || isExpired(configured.expiresAt)) {
|
|
44
|
+
if (storageAllowed) {
|
|
45
|
+
try {
|
|
46
|
+
localStorage.removeItem("drip_debug");
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (configured.active === true || configured.restrictMode === "always") {
|
|
53
|
+
enabled = true;
|
|
54
|
+
} else if (configured.restrictMode === "user_ids") {
|
|
55
|
+
enabled = Boolean(userId && configured.allowedUserIds?.includes(userId));
|
|
56
|
+
} else {
|
|
57
|
+
const queryParam = configured.queryParam || "drip_debug";
|
|
58
|
+
const value = getQueryValue(queryParam);
|
|
59
|
+
if (value === "0" || value === "false") {
|
|
60
|
+
if (storageAllowed) {
|
|
61
|
+
try {
|
|
62
|
+
localStorage.removeItem("drip_debug");
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
enabled = false;
|
|
66
|
+
} else {
|
|
67
|
+
enabled = tokenMatches(value, configured.token);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (configured.persist && storageAllowed) {
|
|
72
|
+
try {
|
|
73
|
+
if (enabled) localStorage.setItem("drip_debug", "1");
|
|
74
|
+
else if (localStorage.getItem("drip_debug") === "1") enabled = true;
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return enabled;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isDebugEnabled(): boolean {
|
|
82
|
+
if (enabled) return true;
|
|
83
|
+
if (isExpired(configured.expiresAt)) return false;
|
|
84
|
+
try {
|
|
85
|
+
enabled = configured.enabled === true
|
|
86
|
+
&& configured.persist === true
|
|
87
|
+
&& localStorage.getItem("drip_debug") === "1";
|
|
88
|
+
} catch {
|
|
89
|
+
enabled = false;
|
|
90
|
+
}
|
|
91
|
+
return enabled;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const PREFIX = "%c[drip]";
|
|
95
|
+
const STYLE = "color: #8b5cf6; font-weight: bold;";
|
|
96
|
+
const DIMMED = "color: #6b6f7b;";
|
|
97
|
+
|
|
98
|
+
export function debugLog(category: string, message: string, data?: unknown): void {
|
|
99
|
+
if (!isDebugEnabled()) return;
|
|
100
|
+
if (typeof console === "undefined") return;
|
|
101
|
+
|
|
102
|
+
const timestamp = new Date().toISOString().slice(11, 23);
|
|
103
|
+
if (data !== undefined) {
|
|
104
|
+
console.log(`${PREFIX} %c${timestamp} %c[${category}] ${message}`, STYLE, DIMMED, "", data);
|
|
105
|
+
} else {
|
|
106
|
+
console.log(`${PREFIX} %c${timestamp} %c[${category}] ${message}`, STYLE, DIMMED, "");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function debugWarn(category: string, message: string, data?: unknown): void {
|
|
111
|
+
if (!isDebugEnabled()) return;
|
|
112
|
+
if (typeof console === "undefined") return;
|
|
113
|
+
|
|
114
|
+
const timestamp = new Date().toISOString().slice(11, 23);
|
|
115
|
+
if (data !== undefined) {
|
|
116
|
+
console.warn(`${PREFIX} %c${timestamp} %c[${category}] ${message}`, STYLE, DIMMED, "", data);
|
|
117
|
+
} else {
|
|
118
|
+
console.warn(`${PREFIX} %c${timestamp} %c[${category}] ${message}`, STYLE, DIMMED, "");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function debugGroup(label: string): void {
|
|
123
|
+
if (!isDebugEnabled()) return;
|
|
124
|
+
if (typeof console === "undefined") return;
|
|
125
|
+
console.group(`${PREFIX} ${label}`, STYLE);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function debugGroupEnd(): void {
|
|
129
|
+
if (!isDebugEnabled()) return;
|
|
130
|
+
if (typeof console === "undefined") return;
|
|
131
|
+
console.groupEnd();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @internal — exposed for unit testing only. Tree-shaken from production build. */
|
|
135
|
+
export const __test = {
|
|
136
|
+
isExpired,
|
|
137
|
+
tokenMatches,
|
|
138
|
+
};
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
defineExperiment,
|
|
4
|
+
defineExperiments,
|
|
5
|
+
ExperimentDefinitionError,
|
|
6
|
+
} from "./define";
|
|
7
|
+
|
|
8
|
+
describe("defineExperiment", () => {
|
|
9
|
+
const validDef = {
|
|
10
|
+
slug: "hero-banner",
|
|
11
|
+
name: "Hero Banner Test",
|
|
12
|
+
variations: [
|
|
13
|
+
{ id: "control", weight: 0.5, mutations: [], isControl: true },
|
|
14
|
+
{
|
|
15
|
+
id: "variant-a",
|
|
16
|
+
weight: 0.5,
|
|
17
|
+
mutations: [
|
|
18
|
+
{ selector: ".hero", action: "text" as const, text: "New" },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// --- happy path -----------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
it("returns a valid experiment for a correct definition", () => {
|
|
27
|
+
const result = defineExperiment(validDef);
|
|
28
|
+
expect(result).toBeDefined();
|
|
29
|
+
expect(result.id).toBe("hero-banner");
|
|
30
|
+
expect(result.name).toBe("Hero Banner Test");
|
|
31
|
+
expect(result.variations).toHaveLength(2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns a frozen object", () => {
|
|
35
|
+
const result = defineExperiment(validDef);
|
|
36
|
+
expect(Object.isFrozen(result)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("uses slug as the id in output", () => {
|
|
40
|
+
const result = defineExperiment(validDef);
|
|
41
|
+
expect(result.id).toBe(validDef.slug);
|
|
42
|
+
expect(result.slug).toBe(validDef.slug);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("defaults trafficAllocation to 1", () => {
|
|
46
|
+
const result = defineExperiment(validDef);
|
|
47
|
+
expect(result.trafficAllocation).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("defaults mutations to empty array when omitted from variation", () => {
|
|
51
|
+
const def = {
|
|
52
|
+
slug: "no-mutations",
|
|
53
|
+
name: "No Mutations",
|
|
54
|
+
variations: [
|
|
55
|
+
{ id: "control", weight: 0.5, isControl: true },
|
|
56
|
+
{ id: "variant-a", weight: 0.5 },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
const result = defineExperiment(def);
|
|
60
|
+
expect(result.variations[0].mutations).toEqual([]);
|
|
61
|
+
expect(result.variations[1].mutations).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("defaults isControl to false when omitted", () => {
|
|
65
|
+
const result = defineExperiment(validDef);
|
|
66
|
+
const nonControl = result.variations.find((v) => v.id === "variant-a");
|
|
67
|
+
expect(nonControl?.isControl).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// --- slug validation ------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
it("throws for missing slug", () => {
|
|
73
|
+
const def = { ...validDef, slug: undefined } as any;
|
|
74
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("throws for empty slug", () => {
|
|
78
|
+
const def = { ...validDef, slug: "" };
|
|
79
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("throws for uppercase slug", () => {
|
|
83
|
+
const def = { ...validDef, slug: "Hero-Banner" };
|
|
84
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("throws for slug with special characters", () => {
|
|
88
|
+
const def = { ...validDef, slug: "hero_banner!" };
|
|
89
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("accepts single-char slug", () => {
|
|
93
|
+
// The code only applies the regex when slug.length > 1, so "a" passes.
|
|
94
|
+
const def = { ...validDef, slug: "a" };
|
|
95
|
+
const result = defineExperiment(def);
|
|
96
|
+
expect(result.id).toBe("a");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// --- name validation ------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
it("throws for missing name", () => {
|
|
102
|
+
const def = { ...validDef, name: "" };
|
|
103
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// --- variation validation -------------------------------------------------
|
|
107
|
+
|
|
108
|
+
it("throws for less than 2 variations", () => {
|
|
109
|
+
const def = {
|
|
110
|
+
...validDef,
|
|
111
|
+
variations: [{ id: "control", weight: 1, mutations: [], isControl: true }],
|
|
112
|
+
};
|
|
113
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("throws when no control variation is present", () => {
|
|
117
|
+
const def = {
|
|
118
|
+
...validDef,
|
|
119
|
+
variations: [
|
|
120
|
+
{ id: "a", weight: 0.5, mutations: [] },
|
|
121
|
+
{ id: "b", weight: 0.5, mutations: [] },
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("throws when multiple control variations are present", () => {
|
|
128
|
+
const def = {
|
|
129
|
+
...validDef,
|
|
130
|
+
variations: [
|
|
131
|
+
{ id: "c1", weight: 0.5, mutations: [], isControl: true },
|
|
132
|
+
{ id: "c2", weight: 0.5, mutations: [], isControl: true },
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("throws when weights sum to more than 1 (beyond tolerance)", () => {
|
|
139
|
+
const def = {
|
|
140
|
+
...validDef,
|
|
141
|
+
variations: [
|
|
142
|
+
{ id: "control", weight: 0.6, mutations: [], isControl: true },
|
|
143
|
+
{ id: "variant-a", weight: 0.5, mutations: [] },
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("accepts weights summing to 0.98 (within 0.01 tolerance)", () => {
|
|
150
|
+
const def = {
|
|
151
|
+
...validDef,
|
|
152
|
+
variations: [
|
|
153
|
+
{ id: "control", weight: 0.49, mutations: [], isControl: true },
|
|
154
|
+
{ id: "variant-a", weight: 0.49, mutations: [] },
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
// 0.49 + 0.49 = 0.98, diff from 1 = 0.02 > 0.01 → should throw
|
|
158
|
+
// Wait: Math.abs(0.98 - 1) = 0.02 > 0.01 → throws.
|
|
159
|
+
// Use 0.995 instead: let's check the actual tolerance.
|
|
160
|
+
// Tolerance is 0.01. 0.98 diff = 0.02 → actually throws.
|
|
161
|
+
// Let me use 0.495 + 0.495 = 0.99, diff = 0.01, 0.01 > 0.01 is false → passes.
|
|
162
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("accepts weights summing to 0.995 (within 0.01 tolerance)", () => {
|
|
166
|
+
const def = {
|
|
167
|
+
...validDef,
|
|
168
|
+
variations: [
|
|
169
|
+
{ id: "control", weight: 0.495, mutations: [], isControl: true },
|
|
170
|
+
{ id: "variant-a", weight: 0.5, mutations: [] },
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
// 0.495 + 0.5 = 0.995, diff = 0.005 <= 0.01 → passes
|
|
174
|
+
const result = defineExperiment(def);
|
|
175
|
+
expect(result).toBeDefined();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("throws for duplicate variation IDs", () => {
|
|
179
|
+
const def = {
|
|
180
|
+
...validDef,
|
|
181
|
+
variations: [
|
|
182
|
+
{ id: "same", weight: 0.5, mutations: [], isControl: true },
|
|
183
|
+
{ id: "same", weight: 0.5, mutations: [] },
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("throws for negative variation weight", () => {
|
|
190
|
+
const def = {
|
|
191
|
+
...validDef,
|
|
192
|
+
variations: [
|
|
193
|
+
{ id: "control", weight: -0.1, mutations: [], isControl: true },
|
|
194
|
+
{ id: "variant-a", weight: 1.1, mutations: [] },
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("throws for variation weight greater than 1", () => {
|
|
201
|
+
const def = {
|
|
202
|
+
...validDef,
|
|
203
|
+
variations: [
|
|
204
|
+
{ id: "control", weight: 0, mutations: [], isControl: true },
|
|
205
|
+
{ id: "variant-a", weight: 1.5, mutations: [] },
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// --- trafficAllocation validation -----------------------------------------
|
|
212
|
+
|
|
213
|
+
it("throws for negative trafficAllocation", () => {
|
|
214
|
+
const def = { ...validDef, trafficAllocation: -0.1 };
|
|
215
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("throws for trafficAllocation above 1", () => {
|
|
219
|
+
const def = { ...validDef, trafficAllocation: 1.1 };
|
|
220
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("accepts trafficAllocation = 0", () => {
|
|
224
|
+
const def = { ...validDef, trafficAllocation: 0 };
|
|
225
|
+
const result = defineExperiment(def);
|
|
226
|
+
expect(result.trafficAllocation).toBe(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("accepts trafficAllocation = 0.5", () => {
|
|
230
|
+
const def = { ...validDef, trafficAllocation: 0.5 };
|
|
231
|
+
const result = defineExperiment(def);
|
|
232
|
+
expect(result.trafficAllocation).toBe(0.5);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// --- goal validation ------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
it("throws for duplicate goal IDs", () => {
|
|
238
|
+
const def = {
|
|
239
|
+
...validDef,
|
|
240
|
+
goals: [
|
|
241
|
+
{ id: "click-cta", type: "click" as const, selector: ".cta" },
|
|
242
|
+
{ id: "click-cta", type: "click" as const, selector: ".other" },
|
|
243
|
+
],
|
|
244
|
+
};
|
|
245
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("throws for click goal without selector", () => {
|
|
249
|
+
const def = {
|
|
250
|
+
...validDef,
|
|
251
|
+
goals: [{ id: "click-cta", type: "click" as const }],
|
|
252
|
+
};
|
|
253
|
+
expect(() => defineExperiment(def)).toThrow(ExperimentDefinitionError);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// --- error shape ----------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
it("error has issues array with specific messages", () => {
|
|
259
|
+
const def = {
|
|
260
|
+
slug: "",
|
|
261
|
+
name: "",
|
|
262
|
+
variations: [],
|
|
263
|
+
} as any;
|
|
264
|
+
try {
|
|
265
|
+
defineExperiment(def);
|
|
266
|
+
expect.unreachable("should have thrown");
|
|
267
|
+
} catch (e) {
|
|
268
|
+
expect(e).toBeInstanceOf(ExperimentDefinitionError);
|
|
269
|
+
const err = e as ExperimentDefinitionError;
|
|
270
|
+
expect(Array.isArray(err.issues)).toBe(true);
|
|
271
|
+
expect(err.issues.length).toBeGreaterThan(0);
|
|
272
|
+
expect(err.issues.some((i) => i.includes("slug"))).toBe(true);
|
|
273
|
+
expect(err.issues.some((i) => i.includes("name"))).toBe(true);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("defineExperiments", () => {
|
|
279
|
+
it("throws on first invalid definition in batch", () => {
|
|
280
|
+
const defs = [
|
|
281
|
+
{
|
|
282
|
+
slug: "INVALID",
|
|
283
|
+
name: "Bad",
|
|
284
|
+
variations: [
|
|
285
|
+
{ id: "control", weight: 0.5, mutations: [], isControl: true },
|
|
286
|
+
{ id: "v", weight: 0.5, mutations: [] },
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
slug: "valid-one",
|
|
291
|
+
name: "Good",
|
|
292
|
+
variations: [
|
|
293
|
+
{ id: "control", weight: 0.5, mutations: [], isControl: true },
|
|
294
|
+
{ id: "v", weight: 0.5, mutations: [] },
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
expect(() => defineExperiments(defs)).toThrow(ExperimentDefinitionError);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("returns array of frozen experiments for valid batch", () => {
|
|
302
|
+
const defs = [
|
|
303
|
+
{
|
|
304
|
+
slug: "exp-one",
|
|
305
|
+
name: "Exp One",
|
|
306
|
+
variations: [
|
|
307
|
+
{ id: "control", weight: 0.5, mutations: [], isControl: true },
|
|
308
|
+
{ id: "v", weight: 0.5, mutations: [] },
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
slug: "exp-two",
|
|
313
|
+
name: "Exp Two",
|
|
314
|
+
variations: [
|
|
315
|
+
{ id: "control", weight: 0.5, mutations: [], isControl: true },
|
|
316
|
+
{ id: "v", weight: 0.5, mutations: [] },
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
];
|
|
320
|
+
const results = defineExperiments(defs);
|
|
321
|
+
expect(results).toHaveLength(2);
|
|
322
|
+
expect(results[0].id).toBe("exp-one");
|
|
323
|
+
expect(results[1].id).toBe("exp-two");
|
|
324
|
+
expect(Object.isFrozen(results[0])).toBe(true);
|
|
325
|
+
expect(Object.isFrozen(results[1])).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
});
|