@drip-apex/sdk 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/bundle-report.json +584 -226
- package/dist/define.cjs +123 -0
- package/dist/define.cjs.map +7 -0
- package/dist/define.mjs +102 -0
- package/dist/define.mjs.map +7 -0
- package/dist/drip.cjs +19262 -0
- package/dist/drip.cjs.map +7 -0
- package/dist/drip.debug.js +1 -1
- package/dist/drip.debug.js.map +1 -1
- package/dist/drip.heatmap.js +1 -1
- package/dist/drip.heatmap.js.map +1 -1
- package/dist/drip.js +1 -1
- package/dist/drip.js.map +1 -1
- package/dist/drip.live.js +1 -1
- package/dist/drip.live.js.map +1 -1
- package/dist/drip.mjs +19246 -0
- package/dist/drip.mjs.map +7 -0
- package/dist/drip.replay-recorder.js +2 -0
- package/dist/drip.replay-recorder.js.map +1 -0
- package/dist/editor.js +147 -142
- package/dist/editor.js.map +4 -4
- package/dist/flags.cjs +1788 -0
- package/dist/flags.cjs.map +7 -0
- package/dist/flags.mjs +1765 -0
- package/dist/flags.mjs.map +7 -0
- package/dist/index.mjs +12 -0
- package/dist/index.mjs.map +2 -2
- package/dist/metafiles/define.cjs.meta.json +28 -0
- package/dist/metafiles/define.mjs.meta.json +32 -0
- package/dist/metafiles/drip.cjs.meta.json +3193 -0
- package/dist/metafiles/drip.debug.meta.json +2859 -574
- package/dist/metafiles/drip.heatmap.meta.json +2732 -460
- package/dist/metafiles/drip.live.meta.json +2732 -460
- package/dist/metafiles/drip.meta.json +2859 -574
- package/dist/metafiles/drip.mjs.meta.json +3235 -0
- package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
- package/dist/metafiles/editor.meta.json +67 -27
- package/dist/metafiles/flags.cjs.meta.json +220 -0
- package/dist/metafiles/flags.mjs.meta.json +225 -0
- package/dist/metafiles/index.meta.json +2717 -491
- package/dist/metafiles/index.mjs.meta.json +14 -4
- package/dist/react/index.js +16621 -13539
- package/dist/react/index.js.map +4 -4
- package/dist/sri.json +20 -15
- package/dist/types/activation-runtime.d.ts +91 -0
- package/dist/types/analytics-runtime.d.ts +166 -0
- package/dist/types/antiflicker.d.ts +9 -3
- package/dist/types/assignment-cookie-sync.d.ts +31 -0
- package/dist/types/assignment-runtime.d.ts +257 -0
- package/dist/types/assignment-store.d.ts +4 -8
- package/dist/types/bucket.d.ts +1 -2
- package/dist/types/compact-store.d.ts +1 -0
- package/dist/types/config-runtime.d.ts +313 -0
- package/dist/types/consent-evaluation.d.ts +27 -0
- package/dist/types/css.d.ts +5 -3
- package/dist/types/debug-loader.d.ts +2 -0
- package/dist/types/decide-assignments.d.ts +31 -0
- package/dist/types/delivery-timing.d.ts +8 -0
- package/dist/types/early-interaction-capture.d.ts +79 -0
- package/dist/types/early-reassert.d.ts +32 -0
- package/dist/types/edge-runtime.d.ts +11 -0
- package/dist/types/editor/api.d.ts +3 -0
- package/dist/types/editor/controller.d.ts +3 -1
- package/dist/types/editor/publication-result.d.ts +11 -0
- package/dist/types/editor/types.d.ts +2 -1
- package/dist/types/editor/view-kit.d.ts +1 -1
- package/dist/types/endpoint.d.ts +8 -0
- package/dist/types/exclusion-groups.d.ts +4 -7
- package/dist/types/flags.d.ts +2 -0
- package/dist/types/goal-eligibility.d.ts +4 -0
- package/dist/types/goals.d.ts +10 -1
- package/dist/types/guards.d.ts +7 -7
- package/dist/types/handover.d.ts +12 -1
- package/dist/types/hash.d.ts +8 -4
- package/dist/types/hydration.d.ts +2 -1
- package/dist/types/index.d.ts +24 -128
- package/dist/types/init-test-helpers.d.ts +48 -0
- package/dist/types/inline-bootstrap.d.ts +26 -0
- package/dist/types/js.d.ts +3 -1
- package/dist/types/money.d.ts +12 -0
- package/dist/types/mutate.d.ts +45 -6
- package/dist/types/performance.d.ts +2 -0
- package/dist/types/persistence-decoders.d.ts +12 -0
- package/dist/types/public-api.d.ts +209 -0
- package/dist/types/qa-assistant-state.d.ts +34 -0
- package/dist/types/react/index.d.ts +28 -3
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/redirect.d.ts +48 -0
- package/dist/types/replay-recorder-entry.d.ts +1 -0
- package/dist/types/replay-signal-state.d.ts +6 -0
- package/dist/types/replay-signals.d.ts +13 -0
- package/dist/types/replay.d.ts +110 -0
- package/dist/types/router.d.ts +12 -2
- package/dist/types/runtime-state.d.ts +166 -0
- package/dist/types/sanitize.d.ts +1 -0
- package/dist/types/shadow.d.ts +3 -0
- package/dist/types/shopify-cart.d.ts +25 -1
- package/dist/types/storefront-vitals.d.ts +24 -0
- package/dist/types/targeting-diagnostics.d.ts +1 -0
- package/dist/types/targeting.d.ts +16 -2
- package/dist/types/track.d.ts +15 -1
- package/dist/types/types.d.ts +115 -9
- package/dist/types/wait.d.ts +14 -1
- package/dist/types/weekday-schedule.d.ts +3 -0
- package/package.json +17 -8
- package/src/activation-runtime.ts +2737 -0
- package/src/analytics-runtime.ts +2175 -0
- package/src/antiflicker.test.ts +216 -44
- package/src/antiflicker.ts +103 -28
- package/src/artifact-concurrency.test.ts +1091 -0
- package/src/assignment-cookie-sync.test.ts +202 -0
- package/src/assignment-cookie-sync.ts +189 -0
- package/src/assignment-runtime.ts +3204 -0
- package/src/assignment-store.test.ts +56 -0
- package/src/assignment-store.ts +7 -38
- package/src/bucket.ts +3 -18
- package/src/commerce-analytics-lifecycle.test.ts +75 -15
- package/src/commerce-config-lifecycle.test.ts +81 -1
- package/src/commerce-price-preserve.test.ts +133 -0
- package/src/commerce-price.test.ts +97 -0
- package/src/commerce-price.ts +74 -20
- package/src/commerce-shipping.test.ts +53 -0
- package/src/commerce-shipping.ts +5 -11
- package/src/compact-store.ts +15 -0
- package/src/config-fetch.test.ts +480 -9
- package/src/config-runtime.ts +2070 -0
- package/src/consent-evaluation-cookiescript.test.ts +50 -0
- package/src/consent-evaluation.ts +138 -0
- package/src/css.test.ts +30 -0
- package/src/css.ts +120 -6
- package/src/cwv.test.ts +60 -77
- package/src/debug-loader.test.ts +214 -0
- package/src/debug-loader.ts +41 -0
- package/src/decide-assignments.test.ts +196 -0
- package/src/decide-assignments.ts +204 -0
- package/src/delivery-timing.test.ts +104 -0
- package/src/delivery-timing.ts +89 -0
- package/src/early-interaction-capture.test.ts +451 -0
- package/src/early-interaction-capture.ts +230 -0
- package/src/early-reassert.test.ts +207 -0
- package/src/early-reassert.ts +152 -0
- package/src/edge-adoption.test.ts +435 -0
- package/src/edge-runtime.ts +30 -0
- package/src/editor/api.test.ts +109 -0
- package/src/editor/api.ts +34 -3
- package/src/editor/controller.test.ts +297 -14
- package/src/editor/controller.ts +69 -5
- package/src/editor/index.test.ts +2 -0
- package/src/editor/index.ts +1 -0
- package/src/editor/publication-result.ts +58 -0
- package/src/editor/types.ts +10 -1
- package/src/editor/view-kit.ts +2 -1
- package/src/editor/view.test.ts +108 -0
- package/src/editor/view.ts +49 -7
- package/src/endpoint.test.ts +15 -0
- package/src/endpoint.ts +10 -0
- package/src/exclusion-groups.ts +8 -18
- package/src/flags.test.ts +88 -1
- package/src/flags.ts +29 -29
- package/src/global-runtime-reconciler.test.ts +271 -0
- package/src/goal-eligibility.test.ts +29 -0
- package/src/goal-eligibility.ts +17 -0
- package/src/goals.test.ts +144 -1
- package/src/goals.ts +97 -18
- package/src/guards.test.ts +99 -1
- package/src/guards.ts +34 -15
- package/src/handover-cache-order.test.ts +6 -3
- package/src/handover.test.ts +154 -8
- package/src/handover.ts +122 -12
- package/src/hash.ts +29 -54
- package/src/holdout.test.ts +26 -0
- package/src/hydration.test.ts +73 -2
- package/src/hydration.ts +18 -9
- package/src/index.ts +130 -7287
- package/src/init-analytics.test.ts +1694 -0
- package/src/init-anti-flicker.test.ts +1211 -0
- package/src/init-cart-attributes.test.ts +2003 -0
- package/src/init-config-resolution.test.ts +999 -0
- package/src/init-consent.test.ts +971 -0
- package/src/init-empty.test.ts +11 -5406
- package/src/init-integrations.test.ts +846 -0
- package/src/init-page-activation.test.ts +5064 -0
- package/src/init-qa-loading.test.ts +740 -0
- package/src/init-redirects.test.ts +2156 -0
- package/src/init-test-helpers.ts +287 -0
- package/src/inline-bootstrap.test.ts +184 -0
- package/src/inline-bootstrap.ts +73 -0
- package/src/js.test.ts +51 -1
- package/src/js.ts +86 -2
- package/src/money.test.ts +55 -1
- package/src/money.ts +104 -2
- package/src/money.web-parity.contract.test.ts +85 -0
- package/src/mutate.test.ts +474 -10
- package/src/mutate.ts +739 -230
- package/src/page-trigger.test.ts +218 -8
- package/src/performance.ts +10 -0
- package/src/persistence-decoders.ts +18 -0
- package/src/public-api.ts +1828 -0
- package/src/qa-assistant-state.test.ts +249 -3
- package/src/qa-assistant-state.ts +146 -6
- package/src/qa-assistant-view.test.ts +220 -2
- package/src/qa-assistant-view.ts +102 -5
- package/src/react/index.test.tsx +37 -0
- package/src/react/index.tsx +19 -3
- package/src/reassert-sentinel.test.ts +155 -1
- package/src/reassert-sentinel.ts +168 -8
- package/src/redirect.test.ts +541 -0
- package/src/redirect.ts +572 -0
- package/src/replay-recorder-entry.ts +115 -0
- package/src/replay-signal-state.ts +100 -0
- package/src/replay-signals.test.ts +249 -0
- package/src/replay-signals.ts +151 -0
- package/src/replay.test.ts +764 -0
- package/src/replay.ts +514 -0
- package/src/router.test.ts +61 -2
- package/src/router.ts +50 -12
- package/src/runtime-state.ts +507 -0
- package/src/runtime.test.ts +174 -7
- package/src/runtime.ts +88 -14
- package/src/sanitize.test.ts +34 -0
- package/src/sanitize.ts +23 -12
- package/src/shadow.test.ts +187 -0
- package/src/shadow.ts +99 -0
- package/src/shopify-cart.test.ts +733 -23
- package/src/shopify-cart.ts +402 -49
- package/src/storefront-vitals.test.ts +217 -0
- package/src/storefront-vitals.ts +206 -0
- package/src/surface-signals.ts +6 -0
- package/src/targeting-conformance.test.ts +9 -0
- package/src/targeting-diagnostics.ts +38 -8
- package/src/targeting-parity-corpus.json +559 -0
- package/src/targeting-parity-corpus.test.ts +18 -0
- package/src/targeting.detailed.test.ts +105 -0
- package/src/targeting.test.ts +211 -0
- package/src/targeting.ts +88 -207
- package/src/track.test.ts +147 -0
- package/src/track.ts +125 -26
- package/src/types.ts +120 -13
- package/src/wait.test.ts +23 -0
- package/src/wait.ts +129 -13
- package/src/weekday-delivery.test.ts +56 -0
- package/src/weekday-schedule-parity-corpus.json +92 -0
- package/src/weekday-schedule.test.ts +69 -0
- package/src/weekday-schedule.ts +20 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
type Listener = () => void;
|
|
4
|
+
|
|
5
|
+
function browserHarness() {
|
|
6
|
+
const documentListeners = new Map<string, Listener>();
|
|
7
|
+
const windowListeners = new Map<string, Listener>();
|
|
8
|
+
const sendBeacon = vi.fn(() => true);
|
|
9
|
+
vi.stubGlobal("document", {
|
|
10
|
+
visibilityState: "visible",
|
|
11
|
+
addEventListener: vi.fn((name: string, listener: Listener) => documentListeners.set(name, listener)),
|
|
12
|
+
removeEventListener: vi.fn(),
|
|
13
|
+
});
|
|
14
|
+
vi.stubGlobal("window", {
|
|
15
|
+
addEventListener: vi.fn((name: string, listener: Listener) => windowListeners.set(name, listener)),
|
|
16
|
+
removeEventListener: vi.fn(),
|
|
17
|
+
});
|
|
18
|
+
vi.stubGlobal("navigator", { sendBeacon });
|
|
19
|
+
vi.stubGlobal("location", { origin: "https://shop.test", pathname: "/products/a" });
|
|
20
|
+
vi.stubGlobal("fetch", vi.fn());
|
|
21
|
+
return { documentListeners, windowListeners, sendBeacon };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("storefront vitals", () => {
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
const { resetStorefrontVitalsForTest } = await import("./storefront-vitals");
|
|
27
|
+
resetStorefrontVitalsForTest();
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
vi.resetModules();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps one page-level sampling decision across reconfiguration", async () => {
|
|
33
|
+
const { sendBeacon, windowListeners } = browserHarness();
|
|
34
|
+
vi.stubGlobal("PerformanceObserver", undefined);
|
|
35
|
+
const { startStorefrontVitals } = await import("./storefront-vitals");
|
|
36
|
+
const random = vi.fn(() => 0.05);
|
|
37
|
+
const base = { endpoint: "https://events.test", shopId: "shop", experimentActive: false };
|
|
38
|
+
startStorefrontVitals({ ...base, sampleRate: 0.1 }, random);
|
|
39
|
+
startStorefrontVitals({ ...base, sampleRate: 0 }, random);
|
|
40
|
+
windowListeners.get("pagehide")?.();
|
|
41
|
+
expect(random).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(sendBeacon).toHaveBeenCalledTimes(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("aggregates observer metrics and flushes only once", async () => {
|
|
46
|
+
const { documentListeners, windowListeners, sendBeacon } = browserHarness();
|
|
47
|
+
const callbacks = new Map<string, PerformanceObserverCallback>();
|
|
48
|
+
vi.stubGlobal("performance", { getEntriesByType: () => [{ responseStart: 123 }] });
|
|
49
|
+
vi.stubGlobal("PerformanceObserver", class {
|
|
50
|
+
constructor(callback: PerformanceObserverCallback) { this.callback = callback; }
|
|
51
|
+
callback: PerformanceObserverCallback;
|
|
52
|
+
observe(options: PerformanceObserverInit) { callbacks.set(String(options.type), this.callback); }
|
|
53
|
+
disconnect() {}
|
|
54
|
+
takeRecords() { return []; }
|
|
55
|
+
});
|
|
56
|
+
const emit = (type: string, entries: unknown[]) => callbacks.get(type)?.(
|
|
57
|
+
{ getEntries: () => entries } as PerformanceObserverEntryList,
|
|
58
|
+
{} as PerformanceObserver,
|
|
59
|
+
);
|
|
60
|
+
const { startStorefrontVitals } = await import("./storefront-vitals");
|
|
61
|
+
startStorefrontVitals({ endpoint: "https://events.test/", shopId: "shop", sampleRate: 1, experimentActive: true });
|
|
62
|
+
expect([...callbacks.keys()]).toEqual(["largest-contentful-paint", "layout-shift", "event"]);
|
|
63
|
+
emit("largest-contentful-paint", [{ startTime: 900, renderTime: 875 }]);
|
|
64
|
+
emit("layout-shift", [
|
|
65
|
+
{ startTime: 100, value: 0.08, hadRecentInput: false },
|
|
66
|
+
{ startTime: 500, value: 0.04, hadRecentInput: false },
|
|
67
|
+
{ startTime: 600, value: 1, hadRecentInput: true },
|
|
68
|
+
]);
|
|
69
|
+
emit("event", [
|
|
70
|
+
{ interactionId: 7, duration: 80 },
|
|
71
|
+
{ interactionId: 7, duration: 120 },
|
|
72
|
+
{ interactionId: 8, duration: 90 },
|
|
73
|
+
]);
|
|
74
|
+
Object.defineProperty(document, "visibilityState", { value: "hidden" });
|
|
75
|
+
documentListeners.get("visibilitychange")?.();
|
|
76
|
+
windowListeners.get("pagehide")?.();
|
|
77
|
+
expect(sendBeacon).toHaveBeenCalledTimes(1);
|
|
78
|
+
const body = JSON.parse(await (sendBeacon.mock.calls[0]![1] as Blob).text());
|
|
79
|
+
expect(body).toMatchObject({ lcp_ms: 875, inp_ms: 120, cls: 0.12, ttfb_navigation_start_to_response_start_ms: 123 });
|
|
80
|
+
// Google-threshold ratings ride on the sample so dashboards and alerts
|
|
81
|
+
// read a bucket without re-deriving thresholds.
|
|
82
|
+
expect(body).toMatchObject({
|
|
83
|
+
lcp_rating: "good",
|
|
84
|
+
inp_rating: "good",
|
|
85
|
+
cls_rating: "needs-improvement",
|
|
86
|
+
ttfb_rating: "good",
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rates each vital against the Google CWV thresholds", async () => {
|
|
91
|
+
const { rateVital } = await import("./storefront-vitals");
|
|
92
|
+
expect(rateVital("lcp_ms", 2_500)).toBe("good");
|
|
93
|
+
expect(rateVital("lcp_ms", 2_501)).toBe("needs-improvement");
|
|
94
|
+
expect(rateVital("lcp_ms", 4_001)).toBe("poor");
|
|
95
|
+
expect(rateVital("inp_ms", 200)).toBe("good");
|
|
96
|
+
expect(rateVital("inp_ms", 501)).toBe("poor");
|
|
97
|
+
expect(rateVital("cls", 0.09)).toBe("good");
|
|
98
|
+
expect(rateVital("cls", 0.26)).toBe("poor");
|
|
99
|
+
expect(rateVital("ttfb_ms", undefined)).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("uses loadTime when an LCP entry has a zero renderTime", async () => {
|
|
103
|
+
const { windowListeners, sendBeacon } = browserHarness();
|
|
104
|
+
let lcpCallback: PerformanceObserverCallback | undefined;
|
|
105
|
+
vi.stubGlobal("performance", { getEntriesByType: () => [] });
|
|
106
|
+
vi.stubGlobal("PerformanceObserver", class {
|
|
107
|
+
constructor(callback: PerformanceObserverCallback) { lcpCallback ??= callback; }
|
|
108
|
+
observe() {}
|
|
109
|
+
disconnect() {}
|
|
110
|
+
takeRecords() { return []; }
|
|
111
|
+
});
|
|
112
|
+
const { startStorefrontVitals } = await import("./storefront-vitals");
|
|
113
|
+
startStorefrontVitals({ endpoint: "https://events.test", shopId: "shop", sampleRate: 1, experimentActive: false });
|
|
114
|
+
lcpCallback?.({ getEntries: () => [{ startTime: 1200, renderTime: 0, loadTime: 1234 }] } as PerformanceObserverEntryList, {} as PerformanceObserver);
|
|
115
|
+
windowListeners.get("pagehide")?.();
|
|
116
|
+
const body = JSON.parse(await (sendBeacon.mock.calls[0]![1] as Blob).text());
|
|
117
|
+
expect(body.lcp_ms).toBe(1234);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("reads deferred anti-flicker reveal duration when the sample flushes", async () => {
|
|
121
|
+
const { windowListeners, sendBeacon } = browserHarness();
|
|
122
|
+
vi.stubGlobal("PerformanceObserver", undefined);
|
|
123
|
+
let revealDuration: number | undefined;
|
|
124
|
+
const { startStorefrontVitals } = await import("./storefront-vitals");
|
|
125
|
+
startStorefrontVitals({
|
|
126
|
+
endpoint: "https://events.test",
|
|
127
|
+
shopId: "shop",
|
|
128
|
+
sampleRate: 1,
|
|
129
|
+
experimentActive: true,
|
|
130
|
+
getAntiFlickerInsertedToRevealMs: () => revealDuration,
|
|
131
|
+
});
|
|
132
|
+
revealDuration = 1450;
|
|
133
|
+
windowListeners.get("pagehide")?.();
|
|
134
|
+
const body = JSON.parse(await (sendBeacon.mock.calls[0]![1] as Blob).text());
|
|
135
|
+
expect(body.anti_flicker_inserted_to_reveal_ms).toBe(1450);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("falls back without observers or sendBeacon", async () => {
|
|
139
|
+
const { windowListeners } = browserHarness();
|
|
140
|
+
vi.stubGlobal("PerformanceObserver", undefined);
|
|
141
|
+
vi.stubGlobal("navigator", {});
|
|
142
|
+
const fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 202 })));
|
|
143
|
+
vi.stubGlobal("fetch", fetch);
|
|
144
|
+
const { startStorefrontVitals } = await import("./storefront-vitals");
|
|
145
|
+
expect(() => startStorefrontVitals({ endpoint: "https://events.test", shopId: "shop", sampleRate: 1, experimentActive: false })).not.toThrow();
|
|
146
|
+
windowListeners.get("pagehide")?.();
|
|
147
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(new URL(String(fetch.mock.calls[0]![0])).pathname).toBe("/storefront-vitals");
|
|
149
|
+
const body = JSON.parse(String(fetch.mock.calls[0]![1]?.body));
|
|
150
|
+
expect(body).not.toHaveProperty("cls");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("uses the same-origin proxy for storefront vitals and retries direct when sendBeacon rejects it", async () => {
|
|
154
|
+
const { windowListeners, sendBeacon } = browserHarness();
|
|
155
|
+
vi.stubGlobal("PerformanceObserver", undefined);
|
|
156
|
+
sendBeacon.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
|
157
|
+
const { startStorefrontVitals } = await import("./storefront-vitals");
|
|
158
|
+
startStorefrontVitals({
|
|
159
|
+
endpoint: "https://events.test",
|
|
160
|
+
proxyEventDelivery: { enabled: true },
|
|
161
|
+
shopId: "shop",
|
|
162
|
+
sampleRate: 1,
|
|
163
|
+
experimentActive: false,
|
|
164
|
+
});
|
|
165
|
+
windowListeners.get("pagehide")?.();
|
|
166
|
+
|
|
167
|
+
expect(sendBeacon).toHaveBeenCalledTimes(2);
|
|
168
|
+
expect(sendBeacon.mock.calls.map(([url]) => String(url))).toEqual([
|
|
169
|
+
"https://shop.test/apps/apex/storefront-vitals",
|
|
170
|
+
"https://events.test/storefront-vitals",
|
|
171
|
+
]);
|
|
172
|
+
const directBody = JSON.parse(await (sendBeacon.mock.calls[1]![1] as Blob).text());
|
|
173
|
+
expect(directBody.delivery_downgrade).toBe("proxy_failed");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("retries a failed proxy fetch directly once", async () => {
|
|
177
|
+
const { windowListeners } = browserHarness();
|
|
178
|
+
vi.stubGlobal("PerformanceObserver", undefined);
|
|
179
|
+
vi.stubGlobal("navigator", {});
|
|
180
|
+
const fetch = vi.fn()
|
|
181
|
+
.mockResolvedValueOnce(new Response(null, { status: 502 }))
|
|
182
|
+
.mockResolvedValueOnce(new Response(null, { status: 202 }));
|
|
183
|
+
vi.stubGlobal("fetch", fetch);
|
|
184
|
+
const { startStorefrontVitals } = await import("./storefront-vitals");
|
|
185
|
+
startStorefrontVitals({
|
|
186
|
+
endpoint: "https://events.test",
|
|
187
|
+
proxyEventDelivery: { enabled: true },
|
|
188
|
+
shopId: "shop",
|
|
189
|
+
sampleRate: 1,
|
|
190
|
+
experimentActive: false,
|
|
191
|
+
});
|
|
192
|
+
windowListeners.get("pagehide")?.();
|
|
193
|
+
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
|
194
|
+
|
|
195
|
+
expect(fetch.mock.calls.map(([url]) => String(url))).toEqual([
|
|
196
|
+
"https://shop.test/apps/apex/storefront-vitals",
|
|
197
|
+
"https://events.test/storefront-vitals",
|
|
198
|
+
]);
|
|
199
|
+
expect(String(fetch.mock.calls[1]![1]?.body)).toContain('"delivery_downgrade":"proxy_failed"');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("reports a legitimate zero CLS when layout shifts are supported", async () => {
|
|
203
|
+
const { windowListeners, sendBeacon } = browserHarness();
|
|
204
|
+
vi.stubGlobal("performance", { getEntriesByType: () => [] });
|
|
205
|
+
vi.stubGlobal("PerformanceObserver", class {
|
|
206
|
+
constructor(_callback: PerformanceObserverCallback) {}
|
|
207
|
+
observe() {}
|
|
208
|
+
disconnect() {}
|
|
209
|
+
takeRecords() { return []; }
|
|
210
|
+
});
|
|
211
|
+
const { startStorefrontVitals } = await import("./storefront-vitals");
|
|
212
|
+
startStorefrontVitals({ endpoint: "https://events.test", shopId: "shop", sampleRate: 1, experimentActive: false });
|
|
213
|
+
windowListeners.get("pagehide")?.();
|
|
214
|
+
const body = JSON.parse(await (sendBeacon.mock.calls[0]![1] as Blob).text());
|
|
215
|
+
expect(body.cls).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
export type StorefrontVitalsContext = {
|
|
2
|
+
endpoint: string;
|
|
3
|
+
proxyEventDelivery?: { enabled: boolean; path?: string };
|
|
4
|
+
shopId: string;
|
|
5
|
+
ingestShopId?: string;
|
|
6
|
+
ingestSignature?: string;
|
|
7
|
+
visitorId?: string;
|
|
8
|
+
sampleRate?: number;
|
|
9
|
+
experimentActive: boolean;
|
|
10
|
+
sdkBootToInitMs?: number;
|
|
11
|
+
getAntiFlickerInsertedToRevealMs?: () => number | undefined;
|
|
12
|
+
sdkInitStartToAssignmentReadyMs?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type LayoutShift = PerformanceEntry & { value?: number; hadRecentInput?: boolean };
|
|
16
|
+
type EventTiming = PerformanceEntry & { duration?: number; interactionId?: number };
|
|
17
|
+
type LargestPaint = PerformanceEntry & { renderTime?: number; loadTime?: number };
|
|
18
|
+
|
|
19
|
+
const observers: Array<{ observer: PerformanceObserver; consume: (entries: PerformanceEntry[]) => void }> = [];
|
|
20
|
+
let cleanup: (() => void) | null = null;
|
|
21
|
+
let pageSampleDecision: boolean | undefined;
|
|
22
|
+
|
|
23
|
+
function finite(value: unknown): number | undefined {
|
|
24
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type VitalRating = "good" | "needs-improvement" | "poor";
|
|
28
|
+
|
|
29
|
+
// Google CWV thresholds (web.dev): the same buckets PageSpeed reports, so
|
|
30
|
+
// dashboards and alerts can read a rating without re-deriving thresholds.
|
|
31
|
+
const RATING_THRESHOLDS: Record<string, [number, number]> = {
|
|
32
|
+
lcp_ms: [2_500, 4_000],
|
|
33
|
+
inp_ms: [200, 500],
|
|
34
|
+
cls: [0.1, 0.25],
|
|
35
|
+
ttfb_ms: [800, 1_800],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function rateVital(metric: keyof typeof RATING_THRESHOLDS, value: number | undefined): VitalRating | undefined {
|
|
39
|
+
const thresholds = RATING_THRESHOLDS[metric];
|
|
40
|
+
if (!thresholds || value === undefined) return undefined;
|
|
41
|
+
return value <= thresholds[0] ? "good" : value <= thresholds[1] ? "needs-improvement" : "poor";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function navigationTtfb(): number | undefined {
|
|
45
|
+
try {
|
|
46
|
+
const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
|
|
47
|
+
return finite(navigation?.responseStart);
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Starts one dependency-free, observer-only storefront vitals sample. */
|
|
54
|
+
export function startStorefrontVitals(context: StorefrontVitalsContext, random = Math.random): void {
|
|
55
|
+
stopStorefrontVitals();
|
|
56
|
+
const rate = finite(context.sampleRate) ?? 0.1;
|
|
57
|
+
if (pageSampleDecision === undefined) pageSampleDecision = rate > 0 && (rate >= 1 || random() < rate);
|
|
58
|
+
if (!pageSampleDecision || !context.endpoint || !context.shopId || typeof document === "undefined") return;
|
|
59
|
+
|
|
60
|
+
let flushed = false;
|
|
61
|
+
let lcpMs: number | undefined;
|
|
62
|
+
let cls = 0;
|
|
63
|
+
let clsSupported = false;
|
|
64
|
+
let clsWindow = 0;
|
|
65
|
+
let clsWindowStart = 0;
|
|
66
|
+
let clsLastShift = 0;
|
|
67
|
+
const interactions = new Map<number, number>();
|
|
68
|
+
|
|
69
|
+
const observe = (type: string, callback: (entries: PerformanceEntry[]) => void, options?: PerformanceObserverInit): boolean => {
|
|
70
|
+
if (typeof PerformanceObserver === "undefined") return false;
|
|
71
|
+
try {
|
|
72
|
+
const observer = new PerformanceObserver((list) => callback(list.getEntries()));
|
|
73
|
+
observer.observe(options ?? { type, buffered: true });
|
|
74
|
+
observers.push({ observer, consume: callback });
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
// Unsupported entry types are expected on older browsers.
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
observe("largest-contentful-paint", (entries) => {
|
|
83
|
+
const last = entries[entries.length - 1] as LargestPaint | undefined;
|
|
84
|
+
if (last) {
|
|
85
|
+
// Cross-origin LCP resources can expose renderTime as zero. A real LCP
|
|
86
|
+
// cannot occur at navigation start, so use the entry's effective time.
|
|
87
|
+
lcpMs = (finite(last.renderTime) || undefined) ?? finite(last.loadTime) ?? finite(last.startTime);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
clsSupported = observe("layout-shift", (entries) => {
|
|
91
|
+
for (const raw of entries) {
|
|
92
|
+
const entry = raw as LayoutShift;
|
|
93
|
+
if (entry.hadRecentInput || finite(entry.value) === undefined) continue;
|
|
94
|
+
if (entry.startTime - clsLastShift > 1_000 || entry.startTime - clsWindowStart > 5_000) {
|
|
95
|
+
clsWindow = 0;
|
|
96
|
+
clsWindowStart = entry.startTime;
|
|
97
|
+
}
|
|
98
|
+
clsWindow += entry.value!;
|
|
99
|
+
clsLastShift = entry.startTime;
|
|
100
|
+
cls = Math.max(cls, clsWindow);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
observe("event", (entries) => {
|
|
104
|
+
for (const raw of entries) {
|
|
105
|
+
const entry = raw as EventTiming;
|
|
106
|
+
const id = finite(entry.interactionId);
|
|
107
|
+
const duration = finite(entry.duration);
|
|
108
|
+
if (!id || duration === undefined) continue;
|
|
109
|
+
// One interaction can produce multiple EventTiming entries. INP uses the
|
|
110
|
+
// longest entry for that interaction, never a double-counted sum.
|
|
111
|
+
interactions.set(id, Math.max(interactions.get(id) ?? 0, duration));
|
|
112
|
+
}
|
|
113
|
+
}, { type: "event", buffered: true, durationThreshold: 40 } as PerformanceObserverInit);
|
|
114
|
+
|
|
115
|
+
const flush = () => {
|
|
116
|
+
if (flushed) return;
|
|
117
|
+
flushed = true;
|
|
118
|
+
const ttfbMs = navigationTtfb();
|
|
119
|
+
for (const { observer, consume } of observers.splice(0)) {
|
|
120
|
+
try { consume(observer.takeRecords()); } catch {}
|
|
121
|
+
observer.disconnect();
|
|
122
|
+
}
|
|
123
|
+
const interactionDurations = [...interactions.values()].sort((a, b) => b - a);
|
|
124
|
+
// The CWV INP estimator ignores one extreme interaction per 50 observed
|
|
125
|
+
// interactions, after first collapsing multiple EventTiming entries by ID.
|
|
126
|
+
const inpMs = interactionDurations[Math.min(Math.floor(interactionDurations.length / 50), interactionDurations.length - 1)];
|
|
127
|
+
const sample = {
|
|
128
|
+
_shopId: context.ingestShopId ?? context.shopId,
|
|
129
|
+
_signature: context.ingestSignature,
|
|
130
|
+
shop_id: context.shopId,
|
|
131
|
+
visitor_id: context.visitorId,
|
|
132
|
+
url: location.origin + location.pathname,
|
|
133
|
+
path: location.pathname,
|
|
134
|
+
lcp_ms: lcpMs,
|
|
135
|
+
inp_ms: inpMs,
|
|
136
|
+
cls: clsSupported ? Math.round(cls * 100_000) / 100_000 : undefined,
|
|
137
|
+
ttfb_navigation_start_to_response_start_ms: ttfbMs,
|
|
138
|
+
lcp_rating: rateVital("lcp_ms", lcpMs),
|
|
139
|
+
inp_rating: rateVital("inp_ms", inpMs),
|
|
140
|
+
cls_rating: clsSupported ? rateVital("cls", cls) : undefined,
|
|
141
|
+
ttfb_rating: rateVital("ttfb_ms", ttfbMs),
|
|
142
|
+
experiment_active: context.experimentActive,
|
|
143
|
+
sdk_boot_to_init_start_ms: context.sdkBootToInitMs,
|
|
144
|
+
// Reveal can happen well after initialization (hydration/deferred holds),
|
|
145
|
+
// so resolve this live rather than freezing an incomplete init snapshot.
|
|
146
|
+
anti_flicker_inserted_to_reveal_ms: context.getAntiFlickerInsertedToRevealMs?.(),
|
|
147
|
+
sdk_init_start_to_assignment_ready_ms: context.sdkInitStartToAssignmentReadyMs,
|
|
148
|
+
timestamp: new Date().toISOString(),
|
|
149
|
+
};
|
|
150
|
+
const payload = JSON.stringify(sample);
|
|
151
|
+
const endpoint = context.endpoint.replace(/\/+$/, "") + "/storefront-vitals";
|
|
152
|
+
const proxyEndpoint = context.proxyEventDelivery?.enabled === true && typeof location?.origin === "string"
|
|
153
|
+
? `${location.origin}/apps/apex/storefront-vitals`
|
|
154
|
+
: null;
|
|
155
|
+
const downgradedPayload = () => JSON.stringify({ ...sample, delivery_downgrade: "proxy_failed" });
|
|
156
|
+
|
|
157
|
+
if (proxyEndpoint) {
|
|
158
|
+
try {
|
|
159
|
+
if (navigator.sendBeacon) {
|
|
160
|
+
if (navigator.sendBeacon(proxyEndpoint, new Blob([payload], { type: "text/plain" }))) return;
|
|
161
|
+
const directPayload = downgradedPayload();
|
|
162
|
+
if (navigator.sendBeacon(endpoint, new Blob([directPayload], { type: "text/plain" }))) return;
|
|
163
|
+
try { void fetch(endpoint, { method: "POST", body: directPayload, headers: { "Content-Type": "text/plain" }, keepalive: true }); } catch {}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
const directPayload = downgradedPayload();
|
|
168
|
+
try {
|
|
169
|
+
if (navigator.sendBeacon?.(endpoint, new Blob([directPayload], { type: "text/plain" }))) return;
|
|
170
|
+
} catch {}
|
|
171
|
+
try { void fetch(endpoint, { method: "POST", body: directPayload, headers: { "Content-Type": "text/plain" }, keepalive: true }); } catch {}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
void fetch(proxyEndpoint, {
|
|
175
|
+
method: "POST", body: payload, headers: { "Content-Type": "text/plain" }, keepalive: true,
|
|
176
|
+
}).then((response) => {
|
|
177
|
+
if (!response.ok) throw new Error(`Proxy storefront vitals returned ${response.status}`);
|
|
178
|
+
}).catch(() => fetch(endpoint, {
|
|
179
|
+
method: "POST", body: downgradedPayload(), headers: { "Content-Type": "text/plain" }, keepalive: true,
|
|
180
|
+
})).catch(() => undefined);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
if (navigator.sendBeacon?.(endpoint, new Blob([payload], { type: "text/plain" }))) return;
|
|
185
|
+
} catch {}
|
|
186
|
+
try { void fetch(endpoint, { method: "POST", body: payload, headers: { "Content-Type": "text/plain" }, keepalive: true }); } catch {}
|
|
187
|
+
};
|
|
188
|
+
const onVisibility = () => { if (document.visibilityState === "hidden") flush(); };
|
|
189
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
190
|
+
window.addEventListener("pagehide", flush, { once: true });
|
|
191
|
+
cleanup = () => {
|
|
192
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
193
|
+
window.removeEventListener("pagehide", flush);
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function stopStorefrontVitals(): void {
|
|
198
|
+
cleanup?.();
|
|
199
|
+
cleanup = null;
|
|
200
|
+
for (const { observer } of observers.splice(0)) observer.disconnect();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function resetStorefrontVitalsForTest(): void {
|
|
204
|
+
stopStorefrontVitals();
|
|
205
|
+
pageSampleDecision = undefined;
|
|
206
|
+
}
|
package/src/surface-signals.ts
CHANGED
|
@@ -111,6 +111,7 @@ export function installCartDrawerOpenListener(
|
|
|
111
111
|
|
|
112
112
|
let current = detectCartDrawerOpen();
|
|
113
113
|
|
|
114
|
+
let stopped = false;
|
|
114
115
|
let timeoutId: number | null = null;
|
|
115
116
|
const check = () => {
|
|
116
117
|
timeoutId = null;
|
|
@@ -120,6 +121,9 @@ export function installCartDrawerOpenListener(
|
|
|
120
121
|
onChange(next);
|
|
121
122
|
};
|
|
122
123
|
const schedule = () => {
|
|
124
|
+
// A mutation record may already be queued when stop() disconnects the
|
|
125
|
+
// observer. Do not let that stale callback re-arm the check timer.
|
|
126
|
+
if (stopped) return;
|
|
123
127
|
if (timeoutId !== null) return;
|
|
124
128
|
timeoutId = window.setTimeout(check, 80);
|
|
125
129
|
};
|
|
@@ -144,10 +148,12 @@ export function installCartDrawerOpenListener(
|
|
|
144
148
|
document.addEventListener("keyup", schedule, true);
|
|
145
149
|
|
|
146
150
|
return () => {
|
|
151
|
+
stopped = true;
|
|
147
152
|
observer.disconnect();
|
|
148
153
|
document.removeEventListener("click", schedule, true);
|
|
149
154
|
document.removeEventListener("keyup", schedule, true);
|
|
150
155
|
if (timeoutId !== null) window.clearTimeout(timeoutId);
|
|
156
|
+
timeoutId = null;
|
|
151
157
|
};
|
|
152
158
|
}
|
|
153
159
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { targetingConformanceCorpus } from "@drip/event-schema/targeting-conformance";
|
|
3
|
+
import { evaluateConformanceCase } from "@drip/event-schema/targeting-core";
|
|
4
|
+
|
|
5
|
+
describe("shared targeting conformance", () => {
|
|
6
|
+
for (const entry of targetingConformanceCorpus) {
|
|
7
|
+
it(entry.name, () => expect(evaluateConformanceCase(entry)).toBe(entry.verdict));
|
|
8
|
+
}
|
|
9
|
+
});
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
} from "./types";
|
|
12
12
|
import {
|
|
13
13
|
evalUrlRule,
|
|
14
|
+
evalUrlRuleForIncludeOrigin,
|
|
14
15
|
evaluateJsTriggerResult,
|
|
15
16
|
evaluatePageAdvancedConditionResult,
|
|
16
17
|
matchAttributeRule,
|
|
@@ -29,6 +30,7 @@ function urlRuleOperator(rule: UrlRule): string {
|
|
|
29
30
|
function diagnoseUrlRules(
|
|
30
31
|
url: string,
|
|
31
32
|
rules: UrlRule[],
|
|
33
|
+
hostAgnosticIncludeOrigin?: string | string[],
|
|
32
34
|
): { matched: boolean; conditions: ConditionDiagnostic[] } {
|
|
33
35
|
const conditions: ConditionDiagnostic[] = [];
|
|
34
36
|
if (!rules.length) return { matched: true, conditions };
|
|
@@ -53,7 +55,13 @@ function diagnoseUrlRules(
|
|
|
53
55
|
continue;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
const rawMatched =
|
|
58
|
+
const rawMatched = hostAgnosticIncludeOrigin
|
|
59
|
+
? evalUrlRuleForIncludeOrigin(
|
|
60
|
+
url,
|
|
61
|
+
rule,
|
|
62
|
+
hostAgnosticIncludeOrigin,
|
|
63
|
+
)
|
|
64
|
+
: evalUrlRule(url, rule);
|
|
57
65
|
const passed = rule.include === false ? !rawMatched : rawMatched;
|
|
58
66
|
conditions.push({
|
|
59
67
|
id: `url:${index}`,
|
|
@@ -276,6 +284,14 @@ function diagnoseSegmentCondition(
|
|
|
276
284
|
): { matched: boolean; diagnostic: ConditionDiagnostic } {
|
|
277
285
|
const resolved = resolveSegmentConditionForDiagnostics(condition, ctx);
|
|
278
286
|
const matched = matchSegmentConditionValues(condition, resolved.actual, resolved.expected);
|
|
287
|
+
const unresolvedActual =
|
|
288
|
+
resolved.actual === undefined ||
|
|
289
|
+
resolved.actual === null ||
|
|
290
|
+
(typeof resolved.actual === "string" && resolved.actual.trim() === "");
|
|
291
|
+
const diagnosticActual =
|
|
292
|
+
condition.signal === "device.type" && resolved.actual === undefined
|
|
293
|
+
? ""
|
|
294
|
+
: resolved.actual;
|
|
279
295
|
return {
|
|
280
296
|
matched,
|
|
281
297
|
diagnostic: segmentConditionDiagnostic(
|
|
@@ -283,8 +299,10 @@ function diagnoseSegmentCondition(
|
|
|
283
299
|
condition,
|
|
284
300
|
index,
|
|
285
301
|
matched ? "passed" : "failed",
|
|
286
|
-
matched
|
|
287
|
-
|
|
302
|
+
matched
|
|
303
|
+
? undefined
|
|
304
|
+
: resolved.jsReason ?? (!condition.js && unresolvedActual ? "signal_unresolved" : "segment_mismatch"),
|
|
305
|
+
diagnosticActual,
|
|
288
306
|
resolved.expected,
|
|
289
307
|
),
|
|
290
308
|
};
|
|
@@ -479,7 +497,11 @@ function skippedSegmentConditions(rules: SegmentTargetingRule[], reason: string)
|
|
|
479
497
|
|
|
480
498
|
export function evaluateTargetingGatesWithConditionDiagnostics(
|
|
481
499
|
targeting: Targeting | undefined,
|
|
482
|
-
ctx: {
|
|
500
|
+
ctx: {
|
|
501
|
+
url: string;
|
|
502
|
+
attributes: Record<string, unknown>;
|
|
503
|
+
hostAgnosticIncludeOrigin?: string | string[];
|
|
504
|
+
},
|
|
483
505
|
): TargetingDiagnostics {
|
|
484
506
|
const conditions: ConditionDiagnostic[] = [];
|
|
485
507
|
if (!targeting) {
|
|
@@ -497,7 +519,11 @@ export function evaluateTargetingGatesWithConditionDiagnostics(
|
|
|
497
519
|
const urlRules = normalizeRuntimeUrlRules(targeting);
|
|
498
520
|
const pageRules = targeting.pageRules ?? [];
|
|
499
521
|
const pageHasRules = urlRules.length > 0 || pageRules.length > 0;
|
|
500
|
-
const url = diagnoseUrlRules(
|
|
522
|
+
const url = diagnoseUrlRules(
|
|
523
|
+
ctx.url,
|
|
524
|
+
urlRules,
|
|
525
|
+
ctx.hostAgnosticIncludeOrigin,
|
|
526
|
+
);
|
|
501
527
|
conditions.push(...url.conditions);
|
|
502
528
|
|
|
503
529
|
let pageRulesMatched = true;
|
|
@@ -521,6 +547,10 @@ export function evaluateTargetingGatesWithConditionDiagnostics(
|
|
|
521
547
|
|
|
522
548
|
const attributeRules = targeting.attributes ?? [];
|
|
523
549
|
const segmentRules = targeting.segmentRules ?? [];
|
|
550
|
+
const segmentRefs = targeting.segments ?? [];
|
|
551
|
+
const segmentsExpanded = segmentRefs.every((ref) =>
|
|
552
|
+
segmentRules.some((rule) => rule.id === ref.id)
|
|
553
|
+
);
|
|
524
554
|
if (!pageMatched) {
|
|
525
555
|
conditions.push(...skippedAttributeConditions(attributeRules, "page_mismatch"));
|
|
526
556
|
conditions.push(...skippedSegmentConditions(segmentRules, "page_mismatch"));
|
|
@@ -540,14 +570,14 @@ export function evaluateTargetingGatesWithConditionDiagnostics(
|
|
|
540
570
|
const segments = diagnoseSegmentRules(segmentRules, ctx);
|
|
541
571
|
conditions.push(...segments.conditions);
|
|
542
572
|
|
|
543
|
-
const audienceHasRules = attributeRules.length > 0 || segmentRules.length > 0;
|
|
544
|
-
const audienceMatched = attributes.matched && segments.matched;
|
|
573
|
+
const audienceHasRules = attributeRules.length > 0 || segmentRules.length > 0 || segmentRefs.length > 0;
|
|
574
|
+
const audienceMatched = segmentsExpanded && attributes.matched && segments.matched;
|
|
545
575
|
gates.push({
|
|
546
576
|
id: "audience",
|
|
547
577
|
label: "Audience check",
|
|
548
578
|
status: audienceHasRules ? (audienceMatched ? "passed" : "failed") : "skipped",
|
|
549
579
|
reason: audienceHasRules
|
|
550
|
-
? (audienceMatched ? "audience_matched" : !attributes.matched ? "attribute_mismatch" : "segment_mismatch")
|
|
580
|
+
? (audienceMatched ? "audience_matched" : !segmentsExpanded ? "segments_unexpanded" : !attributes.matched ? "attribute_mismatch" : "segment_mismatch")
|
|
551
581
|
: "no_audience_rules",
|
|
552
582
|
detail: audienceHasRules ? { attributeRules: attributeRules.length, segmentRules: segmentRules.length } : undefined,
|
|
553
583
|
});
|