@traffical/svelte 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/README.md +365 -0
- package/dist/TrafficalProvider.svelte +34 -0
- package/dist/TrafficalProvider.svelte.d.ts +12 -0
- package/dist/TrafficalProvider.svelte.d.ts.map +1 -0
- package/dist/context.svelte.d.ts +44 -0
- package/dist/context.svelte.d.ts.map +1 -0
- package/dist/context.svelte.js +192 -0
- package/dist/hooks.svelte.d.ts +155 -0
- package/dist/hooks.svelte.d.ts.map +1 -0
- package/dist/hooks.svelte.js +371 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +83 -0
- package/dist/index.test.d.ts +7 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +184 -0
- package/dist/sveltekit.d.ts +73 -0
- package/dist/sveltekit.d.ts.map +1 -0
- package/dist/sveltekit.js +111 -0
- package/dist/sveltekit.test.d.ts +5 -0
- package/dist/sveltekit.test.d.ts.map +1 -0
- package/dist/sveltekit.test.js +170 -0
- package/dist/types.d.ts +206 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/package.json +67 -0
- package/src/TrafficalProvider.svelte +34 -0
- package/src/context.svelte.ts +232 -0
- package/src/hooks.svelte.ts +445 -0
- package/src/index.test.ts +221 -0
- package/src/index.ts +144 -0
- package/src/sveltekit.test.ts +218 -0
- package/src/sveltekit.ts +139 -0
- package/src/types.ts +296 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @traffical/svelte - Hooks
|
|
3
|
+
*
|
|
4
|
+
* Svelte 5 hooks for parameter resolution and decision tracking.
|
|
5
|
+
* Uses runes ($derived, $effect) for reactive, fine-grained updates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolveParameters, decide as coreDecide } from "@traffical/core";
|
|
9
|
+
import type {
|
|
10
|
+
ParameterValue,
|
|
11
|
+
DecisionResult,
|
|
12
|
+
Context,
|
|
13
|
+
} from "@traffical/core";
|
|
14
|
+
import type {
|
|
15
|
+
TrafficalPlugin,
|
|
16
|
+
TrafficalClient,
|
|
17
|
+
} from "@traffical/js-client";
|
|
18
|
+
import { getTrafficalContext } from "./context.svelte.js";
|
|
19
|
+
import type {
|
|
20
|
+
UseTrafficalOptions,
|
|
21
|
+
UseTrafficalResult,
|
|
22
|
+
BoundTrackRewardOptions,
|
|
23
|
+
TrackRewardOptions,
|
|
24
|
+
TrackEventOptions,
|
|
25
|
+
} from "./types.js";
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Browser Detection
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
function isBrowser(): boolean {
|
|
32
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// useTraffical - Primary Hook
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Primary hook for Traffical parameter resolution and decision tracking.
|
|
41
|
+
*
|
|
42
|
+
* Returns reactive values that automatically update when the config bundle changes.
|
|
43
|
+
* On first render, returns defaults immediately (no blocking).
|
|
44
|
+
* When the config bundle loads, recomputes and returns resolved values.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```svelte
|
|
48
|
+
* <script>
|
|
49
|
+
* import { useTraffical } from '@traffical/svelte';
|
|
50
|
+
*
|
|
51
|
+
* // Full tracking (default) - decision + exposure events
|
|
52
|
+
* const { params, decision, ready } = useTraffical({
|
|
53
|
+
* defaults: { "checkout.ctaText": "Buy Now" },
|
|
54
|
+
* });
|
|
55
|
+
* </script>
|
|
56
|
+
*
|
|
57
|
+
* {#if ready}
|
|
58
|
+
* <button>{params['checkout.ctaText']}</button>
|
|
59
|
+
* {:else}
|
|
60
|
+
* <button disabled>Loading...</button>
|
|
61
|
+
* {/if}
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```svelte
|
|
66
|
+
* <script>
|
|
67
|
+
* // Decision tracking only - manual exposure control
|
|
68
|
+
* const { params, decision, trackExposure } = useTraffical({
|
|
69
|
+
* defaults: { "checkout.ctaText": "Buy Now" },
|
|
70
|
+
* tracking: "decision",
|
|
71
|
+
* });
|
|
72
|
+
*
|
|
73
|
+
* // Track exposure when element becomes visible
|
|
74
|
+
* function handleVisible() {
|
|
75
|
+
* trackExposure();
|
|
76
|
+
* }
|
|
77
|
+
* </script>
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```svelte
|
|
82
|
+
* <script>
|
|
83
|
+
* // No tracking - for SSR, tests, or internal logic
|
|
84
|
+
* const { params, ready } = useTraffical({
|
|
85
|
+
* defaults: { "ui.hero.title": "Welcome" },
|
|
86
|
+
* tracking: "none",
|
|
87
|
+
* });
|
|
88
|
+
* </script>
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function useTraffical<T extends Record<string, ParameterValue>>(
|
|
92
|
+
options: UseTrafficalOptions<T>
|
|
93
|
+
): UseTrafficalResult<T> {
|
|
94
|
+
const ctx = getTrafficalContext();
|
|
95
|
+
|
|
96
|
+
const trackingMode = options.tracking ?? "full";
|
|
97
|
+
const shouldTrackDecision = trackingMode !== "none";
|
|
98
|
+
const shouldAutoTrackExposure = trackingMode === "full";
|
|
99
|
+
|
|
100
|
+
// Track whether we've already tracked exposure for this decision
|
|
101
|
+
let hasTrackedExposure = $state(false);
|
|
102
|
+
let currentDecisionId = $state<string | null>(null);
|
|
103
|
+
|
|
104
|
+
// Derive params reactively using $derived.by
|
|
105
|
+
// This is synchronous and provides fine-grained reactivity
|
|
106
|
+
const params = $derived.by((): T => {
|
|
107
|
+
// Priority 1: Resolve from bundle if available
|
|
108
|
+
if (ctx.bundle) {
|
|
109
|
+
const context: Context = {
|
|
110
|
+
...ctx.getContext(),
|
|
111
|
+
...options.context,
|
|
112
|
+
};
|
|
113
|
+
return resolveParameters(ctx.bundle, context, options.defaults);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Priority 2: Use server-provided initial params
|
|
117
|
+
if (ctx.initialParams) {
|
|
118
|
+
return { ...options.defaults, ...ctx.initialParams } as T;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Priority 3: Fall back to defaults
|
|
122
|
+
return options.defaults;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Derive decision reactively
|
|
126
|
+
const decision = $derived.by((): DecisionResult | null => {
|
|
127
|
+
if (!shouldTrackDecision) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!ctx.bundle) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const context: Context = {
|
|
136
|
+
...ctx.getContext(),
|
|
137
|
+
...options.context,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Use client's decide if available (handles tracking internally)
|
|
141
|
+
if (ctx.client) {
|
|
142
|
+
return ctx.client.decide({
|
|
143
|
+
context,
|
|
144
|
+
defaults: options.defaults,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Fall back to core decide (SSR or no client)
|
|
149
|
+
return coreDecide(ctx.bundle, context, options.defaults);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Reset exposure tracking when decision changes
|
|
153
|
+
$effect(() => {
|
|
154
|
+
const decisionId = decision?.decisionId ?? null;
|
|
155
|
+
if (decisionId !== currentDecisionId) {
|
|
156
|
+
currentDecisionId = decisionId;
|
|
157
|
+
hasTrackedExposure = false;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Auto-track exposure when tracking is "full" and decision is available
|
|
162
|
+
$effect(() => {
|
|
163
|
+
if (
|
|
164
|
+
shouldAutoTrackExposure &&
|
|
165
|
+
decision &&
|
|
166
|
+
!hasTrackedExposure &&
|
|
167
|
+
isBrowser()
|
|
168
|
+
) {
|
|
169
|
+
trackExposureInternal();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Internal exposure tracking function
|
|
174
|
+
function trackExposureInternal(): void {
|
|
175
|
+
if (!isBrowser() || !ctx.client || !decision || hasTrackedExposure) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
ctx.client.trackExposure(decision);
|
|
180
|
+
hasTrackedExposure = true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Public exposure tracking function
|
|
184
|
+
function trackExposure(): void {
|
|
185
|
+
if (trackingMode === "none") {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
trackExposureInternal();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Track user events - decisionId is automatically included
|
|
192
|
+
function track(event: string, properties?: Record<string, unknown>): void {
|
|
193
|
+
if (!isBrowser() || !ctx.client) {
|
|
194
|
+
if (!isBrowser()) {
|
|
195
|
+
return; // Silent no-op during SSR
|
|
196
|
+
}
|
|
197
|
+
console.warn("[Traffical] Client not initialized, cannot track event");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Access the reactive decision value
|
|
202
|
+
const currentDecision = decision;
|
|
203
|
+
if (!currentDecision) {
|
|
204
|
+
console.warn(
|
|
205
|
+
"[Traffical] No decision available, cannot track event. Did you use tracking: 'none'?"
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
ctx.client.track(event, properties, {
|
|
211
|
+
decisionId: currentDecision.decisionId,
|
|
212
|
+
unitKey: ctx.getUnitKey(),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Deprecated: Bound reward tracking - decisionId is automatically included
|
|
217
|
+
function trackReward(options: BoundTrackRewardOptions): void {
|
|
218
|
+
if (!isBrowser() || !ctx.client) {
|
|
219
|
+
if (!isBrowser()) {
|
|
220
|
+
return; // Silent no-op during SSR
|
|
221
|
+
}
|
|
222
|
+
console.warn("[Traffical] Client not initialized, cannot track reward");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Access the reactive decision value
|
|
227
|
+
const currentDecision = decision;
|
|
228
|
+
if (!currentDecision) {
|
|
229
|
+
console.warn(
|
|
230
|
+
"[Traffical] No decision available, cannot track reward. Did you use tracking: 'none'?"
|
|
231
|
+
);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Map old API to new track() API
|
|
236
|
+
track(options.rewardType || "reward", {
|
|
237
|
+
value: options.reward,
|
|
238
|
+
...(options.rewards ? { rewards: options.rewards } : {}),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
get params() {
|
|
244
|
+
return params;
|
|
245
|
+
},
|
|
246
|
+
get decision() {
|
|
247
|
+
return decision;
|
|
248
|
+
},
|
|
249
|
+
get ready() {
|
|
250
|
+
return ctx.ready;
|
|
251
|
+
},
|
|
252
|
+
get error() {
|
|
253
|
+
return ctx.error;
|
|
254
|
+
},
|
|
255
|
+
trackExposure,
|
|
256
|
+
track,
|
|
257
|
+
trackReward,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// =============================================================================
|
|
262
|
+
// useTrafficalTrack
|
|
263
|
+
// =============================================================================
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Hook to track user events.
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```svelte
|
|
270
|
+
* <script>
|
|
271
|
+
* import { useTrafficalTrack } from '@traffical/svelte';
|
|
272
|
+
*
|
|
273
|
+
* const track = useTrafficalTrack();
|
|
274
|
+
*
|
|
275
|
+
* function handlePurchase(amount: number) {
|
|
276
|
+
* track({
|
|
277
|
+
* event: 'purchase',
|
|
278
|
+
* properties: { value: amount, orderId: 'ord_123' },
|
|
279
|
+
* });
|
|
280
|
+
* }
|
|
281
|
+
* </script>
|
|
282
|
+
* ```
|
|
283
|
+
*/
|
|
284
|
+
export function useTrafficalTrack(): (options: TrackEventOptions) => void {
|
|
285
|
+
const ctx = getTrafficalContext();
|
|
286
|
+
|
|
287
|
+
return function track(options: TrackEventOptions): void {
|
|
288
|
+
if (!isBrowser() || !ctx.client) {
|
|
289
|
+
if (!isBrowser()) {
|
|
290
|
+
return; // Silent no-op during SSR
|
|
291
|
+
}
|
|
292
|
+
console.warn("[Traffical] Client not initialized, cannot track event");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
ctx.client.track(options.event, options.properties, {
|
|
297
|
+
decisionId: options.decisionId,
|
|
298
|
+
unitKey: ctx.getUnitKey(),
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// =============================================================================
|
|
304
|
+
// useTrafficalReward (deprecated)
|
|
305
|
+
// =============================================================================
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @deprecated Use useTrafficalTrack() instead.
|
|
309
|
+
*
|
|
310
|
+
* Hook to track rewards (conversions, revenue, etc.).
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* ```svelte
|
|
314
|
+
* <script>
|
|
315
|
+
* import { useTraffical, useTrafficalReward } from '@traffical/svelte';
|
|
316
|
+
*
|
|
317
|
+
* const { params, decision } = useTraffical({
|
|
318
|
+
* defaults: { 'checkout.ctaText': 'Buy Now' },
|
|
319
|
+
* });
|
|
320
|
+
*
|
|
321
|
+
* const trackReward = useTrafficalReward();
|
|
322
|
+
*
|
|
323
|
+
* function handlePurchase(amount: number) {
|
|
324
|
+
* trackReward({
|
|
325
|
+
* reward: amount,
|
|
326
|
+
* rewardType: 'revenue',
|
|
327
|
+
* });
|
|
328
|
+
* }
|
|
329
|
+
* </script>
|
|
330
|
+
* ```
|
|
331
|
+
*/
|
|
332
|
+
export function useTrafficalReward(): (options: TrackRewardOptions) => void {
|
|
333
|
+
const ctx = getTrafficalContext();
|
|
334
|
+
|
|
335
|
+
return function trackReward(options: TrackRewardOptions): void {
|
|
336
|
+
if (!isBrowser() || !ctx.client) {
|
|
337
|
+
if (!isBrowser()) {
|
|
338
|
+
return; // Silent no-op during SSR
|
|
339
|
+
}
|
|
340
|
+
console.warn("[Traffical] Client not initialized, cannot track reward");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// We need to ensure decisionId is provided for the core TrackRewardOptions
|
|
345
|
+
// If not provided, we skip tracking (can't attribute without decision)
|
|
346
|
+
if (!options.decisionId) {
|
|
347
|
+
console.warn("[Traffical] trackReward called without decisionId, skipping");
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Map old API to new track() API
|
|
352
|
+
ctx.client.track(options.rewardType || "reward", {
|
|
353
|
+
value: options.reward,
|
|
354
|
+
...(options.rewards ? { rewards: options.rewards } : {}),
|
|
355
|
+
}, {
|
|
356
|
+
decisionId: options.decisionId,
|
|
357
|
+
unitKey: ctx.getUnitKey(),
|
|
358
|
+
});
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// =============================================================================
|
|
363
|
+
// useTrafficalClient
|
|
364
|
+
// =============================================================================
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Hook to access the Traffical client directly.
|
|
368
|
+
*
|
|
369
|
+
* @example
|
|
370
|
+
* ```svelte
|
|
371
|
+
* <script>
|
|
372
|
+
* import { useTrafficalClient } from '@traffical/svelte';
|
|
373
|
+
*
|
|
374
|
+
* const { client, ready, error } = useTrafficalClient();
|
|
375
|
+
*
|
|
376
|
+
* $effect(() => {
|
|
377
|
+
* if (ready && client) {
|
|
378
|
+
* const version = client.getConfigVersion();
|
|
379
|
+
* const stableId = client.getStableId();
|
|
380
|
+
* console.log('Config version:', version, 'Stable ID:', stableId);
|
|
381
|
+
* }
|
|
382
|
+
* });
|
|
383
|
+
* </script>
|
|
384
|
+
* ```
|
|
385
|
+
*/
|
|
386
|
+
export function useTrafficalClient(): {
|
|
387
|
+
readonly client: TrafficalClient | null;
|
|
388
|
+
readonly ready: boolean;
|
|
389
|
+
readonly error: Error | null;
|
|
390
|
+
} {
|
|
391
|
+
const ctx = getTrafficalContext();
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
get client() {
|
|
395
|
+
return ctx.client;
|
|
396
|
+
},
|
|
397
|
+
get ready() {
|
|
398
|
+
return ctx.ready;
|
|
399
|
+
},
|
|
400
|
+
get error() {
|
|
401
|
+
return ctx.error;
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// =============================================================================
|
|
407
|
+
// useTrafficalPlugin
|
|
408
|
+
// =============================================================================
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Hook to access a registered plugin by name.
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* ```svelte
|
|
415
|
+
* <script>
|
|
416
|
+
* import { useTrafficalPlugin } from '@traffical/svelte';
|
|
417
|
+
* import type { DOMBindingPlugin } from '@traffical/js-client';
|
|
418
|
+
*
|
|
419
|
+
* const domPlugin = useTrafficalPlugin<DOMBindingPlugin>('dom-binding');
|
|
420
|
+
*
|
|
421
|
+
* // Re-apply bindings after dynamic content changes
|
|
422
|
+
* $effect(() => {
|
|
423
|
+
* if (contentLoaded) {
|
|
424
|
+
* domPlugin?.applyBindings();
|
|
425
|
+
* }
|
|
426
|
+
* });
|
|
427
|
+
* </script>
|
|
428
|
+
* ```
|
|
429
|
+
*/
|
|
430
|
+
export function useTrafficalPlugin<
|
|
431
|
+
T extends TrafficalPlugin = TrafficalPlugin,
|
|
432
|
+
>(name: string): T | undefined {
|
|
433
|
+
const ctx = getTrafficalContext();
|
|
434
|
+
|
|
435
|
+
// Derive plugin access reactively
|
|
436
|
+
const plugin = $derived.by((): T | undefined => {
|
|
437
|
+
if (!ctx.client || !ctx.ready) {
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
return ctx.client.getPlugin(name) as T | undefined;
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
return plugin;
|
|
444
|
+
}
|
|
445
|
+
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @traffical/svelte - Unit Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the Svelte 5 SDK hooks and utilities.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from "bun:test";
|
|
8
|
+
import { resolveParameters } from "@traffical/core";
|
|
9
|
+
import type { ConfigBundle } from "@traffical/core";
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Test Fixtures
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
const mockBundle: ConfigBundle = {
|
|
16
|
+
version: new Date().toISOString(),
|
|
17
|
+
orgId: "org_test",
|
|
18
|
+
projectId: "proj_test",
|
|
19
|
+
env: "test",
|
|
20
|
+
hashing: {
|
|
21
|
+
unitKey: "userId",
|
|
22
|
+
bucketCount: 10000,
|
|
23
|
+
},
|
|
24
|
+
parameters: [
|
|
25
|
+
{
|
|
26
|
+
key: "checkout.ctaText",
|
|
27
|
+
type: "string",
|
|
28
|
+
default: "Buy Now",
|
|
29
|
+
layerId: "layer_1",
|
|
30
|
+
namespace: "checkout",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: "checkout.ctaColor",
|
|
34
|
+
type: "string",
|
|
35
|
+
default: "#000000",
|
|
36
|
+
layerId: "layer_1",
|
|
37
|
+
namespace: "checkout",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: "feature.newCheckout",
|
|
41
|
+
type: "boolean",
|
|
42
|
+
default: false,
|
|
43
|
+
layerId: "layer_2",
|
|
44
|
+
namespace: "feature",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
layers: [
|
|
48
|
+
{
|
|
49
|
+
id: "layer_1",
|
|
50
|
+
policies: [],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "layer_2",
|
|
54
|
+
policies: [],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
domBindings: [],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const mockBundleWithLayer: ConfigBundle = {
|
|
61
|
+
...mockBundle,
|
|
62
|
+
layers: [
|
|
63
|
+
{
|
|
64
|
+
id: "layer_1",
|
|
65
|
+
policies: [
|
|
66
|
+
{
|
|
67
|
+
id: "policy_1",
|
|
68
|
+
state: "running",
|
|
69
|
+
kind: "static",
|
|
70
|
+
conditions: [],
|
|
71
|
+
allocations: [
|
|
72
|
+
{
|
|
73
|
+
id: "alloc_1",
|
|
74
|
+
name: "control",
|
|
75
|
+
bucketRange: [0, 4999] as [number, number],
|
|
76
|
+
overrides: {
|
|
77
|
+
"checkout.ctaText": "Buy Now",
|
|
78
|
+
"checkout.ctaColor": "#000000",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "alloc_2",
|
|
83
|
+
name: "treatment",
|
|
84
|
+
bucketRange: [5000, 9999] as [number, number],
|
|
85
|
+
overrides: {
|
|
86
|
+
"checkout.ctaText": "Purchase",
|
|
87
|
+
"checkout.ctaColor": "#FF0000",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "layer_2",
|
|
96
|
+
policies: [],
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// =============================================================================
|
|
102
|
+
// Resolution Tests
|
|
103
|
+
// =============================================================================
|
|
104
|
+
|
|
105
|
+
describe("resolveParameters", () => {
|
|
106
|
+
test("returns defaults when bundle is null", () => {
|
|
107
|
+
const defaults = {
|
|
108
|
+
"checkout.ctaText": "Default Text",
|
|
109
|
+
"checkout.ctaColor": "#FFFFFF",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const result = resolveParameters(null, {}, defaults);
|
|
113
|
+
expect(result).toEqual(defaults);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("resolves parameters from bundle defaults", () => {
|
|
117
|
+
const defaults = {
|
|
118
|
+
"checkout.ctaText": "Fallback",
|
|
119
|
+
"checkout.ctaColor": "#FFFFFF",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const result = resolveParameters(mockBundle, { userId: "user_123" }, defaults);
|
|
123
|
+
|
|
124
|
+
expect(result["checkout.ctaText"]).toBe("Buy Now");
|
|
125
|
+
expect(result["checkout.ctaColor"]).toBe("#000000");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("returns defaults for missing parameters", () => {
|
|
129
|
+
const defaults = {
|
|
130
|
+
"checkout.ctaText": "Fallback",
|
|
131
|
+
"nonexistent.param": "Default Value",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const result = resolveParameters(mockBundle, { userId: "user_123" }, defaults);
|
|
135
|
+
|
|
136
|
+
expect(result["checkout.ctaText"]).toBe("Buy Now");
|
|
137
|
+
expect(result["nonexistent.param"]).toBe("Default Value");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("resolves boolean parameters correctly", () => {
|
|
141
|
+
const defaults = {
|
|
142
|
+
"feature.newCheckout": true, // Default to true, bundle has false
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const result = resolveParameters(mockBundle, { userId: "user_123" }, defaults);
|
|
146
|
+
|
|
147
|
+
expect(result["feature.newCheckout"]).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// =============================================================================
|
|
152
|
+
// SSR Behavior Tests
|
|
153
|
+
// =============================================================================
|
|
154
|
+
|
|
155
|
+
describe("SSR behavior", () => {
|
|
156
|
+
test("isBrowser returns false in test environment", () => {
|
|
157
|
+
// In Bun test environment, window is not defined
|
|
158
|
+
const isBrowser =
|
|
159
|
+
typeof window !== "undefined" && typeof document !== "undefined";
|
|
160
|
+
expect(isBrowser).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("resolveParameters works without browser APIs", () => {
|
|
164
|
+
// This verifies that core resolution doesn't depend on browser APIs
|
|
165
|
+
const defaults = {
|
|
166
|
+
"checkout.ctaText": "Fallback",
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const result = resolveParameters(mockBundle, { userId: "ssr_user" }, defaults);
|
|
170
|
+
|
|
171
|
+
expect(result["checkout.ctaText"]).toBe("Buy Now");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// =============================================================================
|
|
176
|
+
// Type Safety Tests
|
|
177
|
+
// =============================================================================
|
|
178
|
+
|
|
179
|
+
describe("type safety", () => {
|
|
180
|
+
test("preserves type inference for defaults", () => {
|
|
181
|
+
const defaults = {
|
|
182
|
+
stringParam: "hello",
|
|
183
|
+
numberParam: 42,
|
|
184
|
+
booleanParam: true,
|
|
185
|
+
} as const;
|
|
186
|
+
|
|
187
|
+
type Defaults = typeof defaults;
|
|
188
|
+
|
|
189
|
+
// Type check - this should compile
|
|
190
|
+
const result: Defaults = resolveParameters(
|
|
191
|
+
mockBundle,
|
|
192
|
+
{},
|
|
193
|
+
defaults
|
|
194
|
+
) as Defaults;
|
|
195
|
+
|
|
196
|
+
expect(typeof result.stringParam).toBe("string");
|
|
197
|
+
expect(typeof result.numberParam).toBe("number");
|
|
198
|
+
expect(typeof result.booleanParam).toBe("boolean");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// =============================================================================
|
|
203
|
+
// Bundle Validation Tests
|
|
204
|
+
// =============================================================================
|
|
205
|
+
|
|
206
|
+
describe("bundle structure", () => {
|
|
207
|
+
test("mock bundle has expected structure", () => {
|
|
208
|
+
expect(mockBundle.orgId).toBe("org_test");
|
|
209
|
+
expect(mockBundle.hashing.unitKey).toBe("userId");
|
|
210
|
+
expect(mockBundle.hashing.bucketCount).toBe(10000);
|
|
211
|
+
expect(mockBundle.parameters).toHaveLength(3);
|
|
212
|
+
expect(mockBundle.layers).toHaveLength(2);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("mock bundle with layer has allocations", () => {
|
|
216
|
+
expect(mockBundleWithLayer.layers).toHaveLength(2);
|
|
217
|
+
expect(mockBundleWithLayer.layers[0].policies).toHaveLength(1);
|
|
218
|
+
expect(mockBundleWithLayer.layers[0].policies[0].allocations).toHaveLength(2);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|