@envive-ai/react-hooks 0.3.63 → 0.3.65
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/dist/application/models/featureGates.cjs +2 -2
- package/dist/application/models/featureGates.d.cts +2 -2
- package/dist/application/models/featureGates.d.ts +2 -2
- package/dist/application/models/featureGates.js +2 -2
- package/dist/application/utils/elementObserver.d.cts +2 -2
- package/dist/atoms/app/index.d.cts +7 -7
- package/dist/atoms/app/index.d.ts +1 -1
- package/dist/atoms/app/variant.d.ts +6 -6
- package/dist/atoms/chat/chatState.d.cts +19 -19
- package/dist/atoms/chat/chatState.d.ts +19 -19
- package/dist/atoms/chat/form.d.cts +2 -2
- package/dist/atoms/chat/form.d.ts +3 -3
- package/dist/atoms/chat/index.d.ts +3 -3
- package/dist/atoms/chat/lastMessage.d.cts +2 -2
- package/dist/atoms/chat/lastMessage.d.ts +2 -2
- package/dist/atoms/chat/messageQueue.d.ts +7 -7
- package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
- package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
- package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
- package/dist/atoms/chat/renderedWidgetRefs.d.ts +3 -3
- package/dist/atoms/chat/replies.d.cts +3 -3
- package/dist/atoms/chat/suggestions.d.ts +3 -3
- package/dist/atoms/envive/enviveConfig.d.cts +14 -14
- package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
- package/dist/atoms/org/customerService.d.cts +6 -6
- package/dist/atoms/org/customerService.d.ts +6 -6
- package/dist/atoms/org/graphqlConfig.d.cts +4 -4
- package/dist/atoms/org/graphqlConfig.d.ts +4 -4
- package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
- package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
- package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
- package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
- package/dist/atoms/search/types.d.cts +1 -1
- package/dist/atoms/search/utils.d.cts +1 -1
- package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
- package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
- package/dist/contexts/pageContext/pageContext.cjs +12 -24
- package/dist/contexts/pageContext/pageContext.js +12 -24
- package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
- package/dist/contexts/types.d.cts +1 -1
- package/dist/contexts/types.d.ts +1 -1
- package/dist/contexts/typesV3.cjs +8 -1
- package/dist/contexts/typesV3.d.cts +23 -6
- package/dist/contexts/typesV3.d.ts +23 -6
- package/dist/contexts/typesV3.js +8 -2
- package/dist/hooks/Intersection/useIntersection.cjs +9 -2
- package/dist/hooks/Intersection/useIntersection.d.cts +2 -2
- package/dist/hooks/Intersection/useIntersection.d.ts +2 -2
- package/dist/hooks/Intersection/useIntersection.js +9 -2
- package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +12 -3
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.cts +2 -1
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.ts +2 -1
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +13 -4
- package/dist/hooks/WidgetLoadDiagnostics/index.cjs +4 -0
- package/dist/hooks/WidgetLoadDiagnostics/index.d.cts +2 -0
- package/dist/hooks/WidgetLoadDiagnostics/index.d.ts +2 -0
- package/dist/hooks/WidgetLoadDiagnostics/index.js +3 -0
- package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.cjs +122 -0
- package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.cts +48 -0
- package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.ts +48 -0
- package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.js +120 -0
- package/dist/hooks/utils.d.cts +1 -1
- package/dist/hooks/utils.d.ts +1 -1
- package/dist/services/amplitudeService/eventNames.cjs +2 -1
- package/dist/services/amplitudeService/eventNames.d.cts +2 -1
- package/dist/services/amplitudeService/eventNames.d.ts +2 -1
- package/dist/services/amplitudeService/eventNames.js +2 -1
- package/dist/services/enviveConfigService/enviveConfigService.cjs +21 -35
- package/dist/services/enviveConfigService/enviveConfigService.d.cts +1 -2
- package/dist/services/enviveConfigService/enviveConfigService.d.ts +1 -2
- package/dist/services/enviveConfigService/enviveConfigService.js +21 -35
- package/dist/services/enviveConfigService/fetchBootstrapConfig.cjs +15 -16
- package/dist/services/enviveConfigService/fetchBootstrapConfig.js +15 -16
- package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +1 -69
- package/dist/services/enviveConfigService/fetchGraphQLConfig.js +2 -69
- package/dist/services/featureFlagService/index.cjs +7 -5
- package/dist/services/featureFlagService/index.d.cts +2 -1
- package/dist/services/featureFlagService/index.d.ts +2 -1
- package/dist/services/featureFlagService/index.js +7 -5
- package/dist/services/ga4ProjectionService/ga4EventSchema.cjs +3 -2
- package/dist/services/ga4ProjectionService/ga4EventSchema.js +3 -2
- package/dist/services/hardcopyService/hardcopyService.cjs +12 -3
- package/dist/services/hardcopyService/hardcopyService.d.cts +3 -1
- package/dist/services/hardcopyService/hardcopyService.d.ts +3 -1
- package/dist/services/hardcopyService/hardcopyService.js +12 -3
- package/package.json +5 -1
- package/src/application/models/featureGates.ts +11 -8
- package/src/contexts/pageContext/__tests__/pageContext.test.tsx +4 -59
- package/src/contexts/pageContext/pageContext.tsx +20 -41
- package/src/contexts/typesV3.ts +22 -4
- package/src/hooks/Intersection/useIntersection.ts +11 -0
- package/src/hooks/TrackComponentVisibleEvent/__tests__/useTrackComponentVisibleEvent.test.tsx +64 -0
- package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +23 -4
- package/src/hooks/WidgetLoadDiagnostics/__tests__/useWidgetLoadDiagnostics.test.ts +161 -0
- package/src/hooks/WidgetLoadDiagnostics/index.ts +5 -0
- package/src/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.ts +178 -0
- package/src/services/amplitudeService/eventNames.ts +4 -0
- package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +72 -158
- package/src/services/enviveConfigService/__tests__/fetchBootstrapConfig.test.ts +111 -13
- package/src/services/enviveConfigService/__tests__/fetchGraphQLConfig.test.ts +161 -490
- package/src/services/enviveConfigService/enviveConfigService.ts +51 -87
- package/src/services/enviveConfigService/fetchBootstrapConfig.ts +74 -66
- package/src/services/enviveConfigService/fetchGraphQLConfig.ts +0 -120
- package/src/services/featureFlagService/__tests__/getFeatureFlagOverrides.test.ts +54 -0
- package/src/services/featureFlagService/index.ts +19 -6
- package/src/services/ga4ProjectionService/ga4EventSchema.ts +5 -0
- package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +16 -5
- package/src/services/hardcopyService/hardcopyService.ts +10 -2
- package/dist/application/models/graphql/queries/getWidgetConfigQuery.cjs +0 -42
- package/dist/application/models/graphql/queries/getWidgetConfigQuery.js +0 -41
- package/src/application/models/graphql/queries/getWidgetConfigQuery.ts +0 -55
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useAtomValue } from 'jotai';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
import { pageVariantInfoAtom } from 'src/atoms/app';
|
|
4
|
+
import { useAmplitude } from 'src/contexts/amplitudeContext/amplitudeContext';
|
|
5
|
+
import { HardcopyResponse } from 'src/contexts/hardcopyContext';
|
|
6
|
+
import { PageVariantInfo } from 'src/contexts/pageContext/types';
|
|
7
|
+
import { WidgetTypeV3 } from 'src/contexts/typesV3';
|
|
8
|
+
import { extractPageContext } from 'src/hooks/WidgetInteraction/utils';
|
|
9
|
+
import { EnviveMetricsEventName } from 'src/services/amplitudeService/amplitudeService';
|
|
10
|
+
|
|
11
|
+
// Fired if a widget is still loading this long after it became visible. Kept at/just above
|
|
12
|
+
// the hardcopy timeout (HARDCOPY_TIMEOUT_MS, 4s) so a normal timeout → static-fallback settles
|
|
13
|
+
// first and does NOT trip a stall; a stall here is a genuine anomaly (e.g. a config hang).
|
|
14
|
+
// Mirrors WIDGET_LOADING_WATCHDOG_MS (the console-only watchdog in withBaseWidget).
|
|
15
|
+
export const WIDGET_LOAD_STALL_MS = 5000;
|
|
16
|
+
|
|
17
|
+
// 'pending' = visibility not confirmed by either path when the event fired (e.g. a
|
|
18
|
+
// deferLoading:false widget that loaded eagerly, before the observer reported).
|
|
19
|
+
export type WidgetVisibilitySource = 'observer' | 'fallback' | 'pending';
|
|
20
|
+
|
|
21
|
+
export interface WidgetLoadDiagnosticsInput {
|
|
22
|
+
/** Gate. No events are emitted unless true. On by default; a Statsig kill switch can disable it. */
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
/** Correlates this widget's mount / rendered / outcome / stall events for a single load. */
|
|
25
|
+
loadId: string;
|
|
26
|
+
widgetType: WidgetTypeV3;
|
|
27
|
+
widgetConfigId?: string;
|
|
28
|
+
/** The widget became visible and started loading its own requests. */
|
|
29
|
+
startLoading: boolean;
|
|
30
|
+
/** This widget's own hardcopy / widgetConfig / uiConfig requests have all settled. */
|
|
31
|
+
ownRequestsSettled: boolean;
|
|
32
|
+
/** Present once hardcopy resolves; carries `servedFrom` (content_loaded vs static_fallback). */
|
|
33
|
+
hardcopyContent?: HardcopyResponse;
|
|
34
|
+
/** Whether visibility came from the observer or the reconcile fallback. */
|
|
35
|
+
visibilitySource: WidgetVisibilitySource;
|
|
36
|
+
/** Names of this widget's still-unsettled requests, reported on a stall. */
|
|
37
|
+
pendingRequests: string[];
|
|
38
|
+
/** Set when this widget's own hardcopy request rejected (no content — not a static fallback). */
|
|
39
|
+
hardcopyErrorReason?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const connectionType = (): string | undefined => {
|
|
43
|
+
try {
|
|
44
|
+
// Network Information API — Chromium/Android only, undefined on iOS Safari (our key population).
|
|
45
|
+
return (navigator as Navigator & { connection?: { effectiveType?: string } }).connection
|
|
46
|
+
?.effectiveType;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const now = (): number => {
|
|
53
|
+
try {
|
|
54
|
+
return typeof window !== 'undefined' && window.performance?.now
|
|
55
|
+
? window.performance.now()
|
|
56
|
+
: Date.now();
|
|
57
|
+
} catch {
|
|
58
|
+
return Date.now();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Emits the layered `[Envive] Diagnostics` widget-load net so eng can measure not just what
|
|
64
|
+
* loads, but the negative space between layers — the failures that fire nothing today:
|
|
65
|
+
*
|
|
66
|
+
* widget_mounted (ungated) → [Envive] Widget Rendered (visible) → widget_load_outcome
|
|
67
|
+
*
|
|
68
|
+
* - `widget_mounted` fires on mount regardless of visibility, so a widget that mounts but never
|
|
69
|
+
* becomes visible (the suspected infinite-load crux) shows up as a *mounted with no rendered*
|
|
70
|
+
* gap instead of being invisible to analytics entirely.
|
|
71
|
+
* - `widget_load_outcome` fires once the widget's own requests settle
|
|
72
|
+
* (content_loaded / static_fallback / error).
|
|
73
|
+
* - `widget_load_stalled` fires if it's still loading at WIDGET_LOAD_STALL_MS AND still mounted.
|
|
74
|
+
* The timer is cleared on unmount, so a shopper who navigates away first never trips it —
|
|
75
|
+
* "stalled with no later outcome" is therefore a clean stuck signal, undiluted by abandonment.
|
|
76
|
+
*
|
|
77
|
+
* `load_id` correlates all of the above (and is threaded onto `[Envive] Widget Rendered`) so
|
|
78
|
+
* the gaps are joinable per load.
|
|
79
|
+
*/
|
|
80
|
+
export const useWidgetLoadDiagnostics = (input: WidgetLoadDiagnosticsInput): void => {
|
|
81
|
+
const {
|
|
82
|
+
enabled,
|
|
83
|
+
loadId,
|
|
84
|
+
widgetType,
|
|
85
|
+
widgetConfigId,
|
|
86
|
+
startLoading,
|
|
87
|
+
ownRequestsSettled,
|
|
88
|
+
hardcopyContent,
|
|
89
|
+
} = input;
|
|
90
|
+
|
|
91
|
+
const { trackEvent } = useAmplitude();
|
|
92
|
+
const variantInfo = useAtomValue(pageVariantInfoAtom);
|
|
93
|
+
|
|
94
|
+
// Latest input, read inside the deferred stall/outcome callbacks (pendingRequests and
|
|
95
|
+
// visibilitySource churn as requests settle / the fallback engages).
|
|
96
|
+
const inputRef = useRef(input);
|
|
97
|
+
inputRef.current = input;
|
|
98
|
+
|
|
99
|
+
const emit = (props: Record<string, unknown>) => {
|
|
100
|
+
trackEvent({
|
|
101
|
+
eventName: EnviveMetricsEventName.Diagnostics,
|
|
102
|
+
eventProps: {
|
|
103
|
+
load_id: loadId,
|
|
104
|
+
widget_type: widgetType,
|
|
105
|
+
widget_config_id: widgetConfigId,
|
|
106
|
+
connection_type: connectionType(),
|
|
107
|
+
context: variantInfo ? extractPageContext(variantInfo as PageVariantInfo) : null,
|
|
108
|
+
...props,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Layer 0 — mount. Ungated by visibility: the baseline that fires even when the widget never
|
|
114
|
+
// becomes visible, so never-visible failures are a measurable gap rather than silence.
|
|
115
|
+
const mountFiredRef = useRef(false);
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!enabled || mountFiredRef.current) return;
|
|
118
|
+
mountFiredRef.current = true;
|
|
119
|
+
emit({ type: 'widget_mounted' });
|
|
120
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
121
|
+
}, [enabled]);
|
|
122
|
+
|
|
123
|
+
// t0 — when the widget became visible / started loading.
|
|
124
|
+
const visibleAtRef = useRef<number | null>(null);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (startLoading && visibleAtRef.current === null) visibleAtRef.current = now();
|
|
127
|
+
}, [startLoading]);
|
|
128
|
+
|
|
129
|
+
// Layer 2 — terminal outcome, once, when this widget's own requests settle.
|
|
130
|
+
const outcomeFiredRef = useRef(false);
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!enabled || !startLoading || !ownRequestsSettled || outcomeFiredRef.current) return;
|
|
133
|
+
outcomeFiredRef.current = true;
|
|
134
|
+
|
|
135
|
+
const elapsed_ms =
|
|
136
|
+
visibleAtRef.current !== null ? Math.round(now() - visibleAtRef.current) : undefined;
|
|
137
|
+
|
|
138
|
+
let outcome: string;
|
|
139
|
+
let reason: string | undefined;
|
|
140
|
+
if (!hardcopyContent) {
|
|
141
|
+
outcome = 'error';
|
|
142
|
+
reason = inputRef.current.hardcopyErrorReason ?? 'no_content';
|
|
143
|
+
} else if (hardcopyContent.servedFrom === 'static') {
|
|
144
|
+
outcome = 'static_fallback';
|
|
145
|
+
reason = hardcopyContent.fallbackReason;
|
|
146
|
+
} else {
|
|
147
|
+
outcome = 'content_loaded';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
emit({
|
|
151
|
+
type: 'widget_load_outcome',
|
|
152
|
+
outcome,
|
|
153
|
+
reason,
|
|
154
|
+
elapsed_ms,
|
|
155
|
+
visibility_source: inputRef.current.visibilitySource,
|
|
156
|
+
});
|
|
157
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
|
+
}, [enabled, startLoading, ownRequestsSettled, hardcopyContent]);
|
|
159
|
+
|
|
160
|
+
// Layer 1.5 — stall. Only arms while loading; cleared on unmount, so abandonment never trips it.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!enabled || !startLoading || ownRequestsSettled) return undefined;
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
const elapsed_ms =
|
|
165
|
+
visibleAtRef.current !== null
|
|
166
|
+
? Math.round(now() - visibleAtRef.current)
|
|
167
|
+
: WIDGET_LOAD_STALL_MS;
|
|
168
|
+
emit({
|
|
169
|
+
type: 'widget_load_stalled',
|
|
170
|
+
elapsed_ms,
|
|
171
|
+
pending_requests: inputRef.current.pendingRequests,
|
|
172
|
+
visibility_source: inputRef.current.visibilitySource,
|
|
173
|
+
});
|
|
174
|
+
}, WIDGET_LOAD_STALL_MS);
|
|
175
|
+
return () => clearTimeout(timer);
|
|
176
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
177
|
+
}, [enabled, startLoading, ownRequestsSettled]);
|
|
178
|
+
};
|
|
@@ -48,4 +48,8 @@ export enum EnviveMetricsEventName {
|
|
|
48
48
|
WidgetTextRequest = 'Widget Text Request',
|
|
49
49
|
WidgetTextResponse = 'Widget Text Response',
|
|
50
50
|
WidgetTextClicked = 'Widget Text Clicked',
|
|
51
|
+
// Operational diagnostics for widget loading (see useWidgetLoadDiagnostics). A single event
|
|
52
|
+
// with a `type` discriminator (widget_mounted | widget_load_outcome | widget_load_stalled)
|
|
53
|
+
// so eng can measure the layered load net and, crucially, the negative space between layers.
|
|
54
|
+
Diagnostics = 'Diagnostics',
|
|
51
55
|
}
|
|
@@ -3,65 +3,15 @@ import { EnviveConfigService } from '../enviveConfigService';
|
|
|
3
3
|
const mockFetch = vi.fn();
|
|
4
4
|
globalThis.fetch = mockFetch;
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Promise.resolve({
|
|
16
|
-
data: {
|
|
17
|
-
me: {
|
|
18
|
-
org: {
|
|
19
|
-
id: 'org-1',
|
|
20
|
-
shortName: 'test-org',
|
|
21
|
-
displayName: 'Test Org',
|
|
22
|
-
domain: 'test.com',
|
|
23
|
-
createdAt: '2024-01-01T00:00:00.000Z',
|
|
24
|
-
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
25
|
-
},
|
|
26
|
-
getWidgetConfig: { productsConfig: {}, gates: [], experimentAssignments: [] },
|
|
27
|
-
},
|
|
28
|
-
},
|
|
29
|
-
}),
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// getWidgetConfig response with the use_unified_config gate ON (plus a real
|
|
34
|
-
// version pin) — the trigger for the /v1/session/bootstrap upgrade.
|
|
35
|
-
function makeGateOnConfigFetch() {
|
|
36
|
-
mockFetch.mockResolvedValueOnce({
|
|
37
|
-
ok: true,
|
|
38
|
-
json: () =>
|
|
39
|
-
Promise.resolve({
|
|
40
|
-
data: {
|
|
41
|
-
me: {
|
|
42
|
-
org: {
|
|
43
|
-
id: 'org-1',
|
|
44
|
-
shortName: 'test-org',
|
|
45
|
-
displayName: 'Test Org',
|
|
46
|
-
domain: 'test.com',
|
|
47
|
-
createdAt: '2024-01-01T00:00:00.000Z',
|
|
48
|
-
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
49
|
-
},
|
|
50
|
-
getWidgetConfig: {
|
|
51
|
-
productsConfig: {},
|
|
52
|
-
gates: [{ name: 'use_unified_config', value: true }],
|
|
53
|
-
experimentAssignments: [],
|
|
54
|
-
resolution: { baseVersion: '1.0.0', baseVersionSource: 'merchant_layer' },
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
}),
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// /v1/session/bootstrap response (REST snake_case). Distinct org id so tests can
|
|
63
|
-
// assert the config actually came from bootstrap, not the getWidgetConfig call.
|
|
64
|
-
function makeBootstrapFetch() {
|
|
6
|
+
// /v1/session/bootstrap is the single session-config call now (getWidgetConfig
|
|
7
|
+
// and the separate GET /v1/org/config were removed). A valid response carries
|
|
8
|
+
// org identity + a NON-EMPTY gates array (the malformed-payload guard requires
|
|
9
|
+
// it) + widget_config. orgId is parameterised so cache tests can assert the
|
|
10
|
+
// config actually came back; urlResolving is null-able for the config-only path.
|
|
11
|
+
function makeBootstrapFetch(
|
|
12
|
+
orgId = 'org-1',
|
|
13
|
+
urlResolving: unknown = { variant_type: 'pdp', specific_details: {}, ready: true },
|
|
14
|
+
) {
|
|
65
15
|
mockFetch.mockResolvedValueOnce({
|
|
66
16
|
ok: true,
|
|
67
17
|
json: () =>
|
|
@@ -69,10 +19,10 @@ function makeBootstrapFetch() {
|
|
|
69
19
|
org_config: {
|
|
70
20
|
org: {
|
|
71
21
|
org: {
|
|
72
|
-
id:
|
|
73
|
-
short_name: '
|
|
74
|
-
display_name: '
|
|
75
|
-
domain: '
|
|
22
|
+
id: orgId,
|
|
23
|
+
short_name: 'test-org',
|
|
24
|
+
display_name: 'Test Org',
|
|
25
|
+
domain: 'test.com',
|
|
76
26
|
status: 'active',
|
|
77
27
|
created_at: '2024-01-01T00:00:00.000Z',
|
|
78
28
|
updated_at: '2024-01-01T00:00:00.000Z',
|
|
@@ -84,7 +34,7 @@ function makeBootstrapFetch() {
|
|
|
84
34
|
},
|
|
85
35
|
widget_config: {
|
|
86
36
|
products_config: {},
|
|
87
|
-
// Assignments come from widget_config (
|
|
37
|
+
// Assignments come from widget_config (ungated).
|
|
88
38
|
experiment_assignments: [
|
|
89
39
|
{
|
|
90
40
|
layer_name: 'ml',
|
|
@@ -95,7 +45,7 @@ function makeBootstrapFetch() {
|
|
|
95
45
|
],
|
|
96
46
|
resolution: { base_version: '1.0.0', base_version_source: 'merchant_layer' },
|
|
97
47
|
},
|
|
98
|
-
url_resolving:
|
|
48
|
+
url_resolving: urlResolving,
|
|
99
49
|
}),
|
|
100
50
|
});
|
|
101
51
|
}
|
|
@@ -125,17 +75,18 @@ afterEach(() => {
|
|
|
125
75
|
});
|
|
126
76
|
|
|
127
77
|
describe('EnviveConfigService caching', () => {
|
|
128
|
-
it('fetches the
|
|
129
|
-
|
|
78
|
+
it('fetches the session config once on a cold cache', async () => {
|
|
79
|
+
makeBootstrapFetch();
|
|
130
80
|
|
|
131
81
|
const result = await makeService().getEnviveConfig();
|
|
132
82
|
|
|
133
83
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(mockFetch.mock.calls[0][0]).toContain('/v1/session/bootstrap');
|
|
134
85
|
expect(result.org.org.id).toBe('org-1');
|
|
135
86
|
});
|
|
136
87
|
|
|
137
88
|
it('serves a warm cache to a new instance with identical params without hitting the network', async () => {
|
|
138
|
-
|
|
89
|
+
makeBootstrapFetch();
|
|
139
90
|
await makeService().getEnviveConfig();
|
|
140
91
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
141
92
|
|
|
@@ -151,13 +102,13 @@ describe('EnviveConfigService caching', () => {
|
|
|
151
102
|
vi.useFakeTimers();
|
|
152
103
|
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
|
153
104
|
|
|
154
|
-
|
|
105
|
+
makeBootstrapFetch();
|
|
155
106
|
await makeService().getEnviveConfig();
|
|
156
107
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
157
108
|
|
|
158
109
|
vi.setSystemTime(new Date('2026-01-01T00:05:01.000Z'));
|
|
159
110
|
|
|
160
|
-
|
|
111
|
+
makeBootstrapFetch();
|
|
161
112
|
await makeService().getEnviveConfig();
|
|
162
113
|
|
|
163
114
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
@@ -168,11 +119,11 @@ describe('EnviveConfigService caching', () => {
|
|
|
168
119
|
async param => {
|
|
169
120
|
window.history.replaceState({}, '', `/?${param}=1.2.3`);
|
|
170
121
|
|
|
171
|
-
|
|
122
|
+
makeBootstrapFetch();
|
|
172
123
|
await makeService().getEnviveConfig();
|
|
173
124
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
174
125
|
|
|
175
|
-
|
|
126
|
+
makeBootstrapFetch();
|
|
176
127
|
await makeService().getEnviveConfig();
|
|
177
128
|
|
|
178
129
|
// No cache read/write happened, so a second instance refetches instead
|
|
@@ -183,33 +134,33 @@ describe('EnviveConfigService caching', () => {
|
|
|
183
134
|
|
|
184
135
|
it('does not read a version-pinned cache entry after the override is removed', async () => {
|
|
185
136
|
window.history.replaceState({}, '', '/?envive_config_version=1.2.3');
|
|
186
|
-
|
|
137
|
+
makeBootstrapFetch();
|
|
187
138
|
await makeService().getEnviveConfig();
|
|
188
139
|
|
|
189
140
|
window.history.replaceState({}, '', '/');
|
|
190
|
-
|
|
141
|
+
makeBootstrapFetch();
|
|
191
142
|
await makeService().getEnviveConfig();
|
|
192
143
|
|
|
193
144
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
194
145
|
});
|
|
195
146
|
|
|
196
147
|
it('keeps separate cache entries per userId', async () => {
|
|
197
|
-
|
|
148
|
+
makeBootstrapFetch();
|
|
198
149
|
await makeService({ userId: 'user-1' }).getEnviveConfig();
|
|
199
150
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
200
151
|
|
|
201
|
-
|
|
152
|
+
makeBootstrapFetch();
|
|
202
153
|
await makeService({ userId: 'user-2' }).getEnviveConfig();
|
|
203
154
|
|
|
204
155
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
205
156
|
});
|
|
206
157
|
|
|
207
158
|
it('keeps separate cache entries per namespace/source/baseUrl/apiKey', async () => {
|
|
208
|
-
|
|
159
|
+
makeBootstrapFetch();
|
|
209
160
|
await makeService({ namespace: 'ns-a' }).getEnviveConfig();
|
|
210
161
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
211
162
|
|
|
212
|
-
|
|
163
|
+
makeBootstrapFetch();
|
|
213
164
|
await makeService({ namespace: 'ns-b' }).getEnviveConfig();
|
|
214
165
|
|
|
215
166
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
@@ -220,7 +171,7 @@ describe('EnviveConfigService caching', () => {
|
|
|
220
171
|
throw new Error('quota exceeded');
|
|
221
172
|
});
|
|
222
173
|
|
|
223
|
-
|
|
174
|
+
makeBootstrapFetch();
|
|
224
175
|
|
|
225
176
|
const result = await makeService().getEnviveConfig();
|
|
226
177
|
|
|
@@ -232,7 +183,7 @@ describe('EnviveConfigService caching', () => {
|
|
|
232
183
|
throw new Error('storage disabled');
|
|
233
184
|
});
|
|
234
185
|
|
|
235
|
-
|
|
186
|
+
makeBootstrapFetch();
|
|
236
187
|
|
|
237
188
|
const result = await makeService().getEnviveConfig();
|
|
238
189
|
|
|
@@ -241,47 +192,34 @@ describe('EnviveConfigService caching', () => {
|
|
|
241
192
|
|
|
242
193
|
it('fails open and refetches when a cached entry is corrupted JSON', async () => {
|
|
243
194
|
const service = makeService();
|
|
244
|
-
|
|
195
|
+
makeBootstrapFetch();
|
|
245
196
|
await service.getEnviveConfig();
|
|
246
197
|
const [key] = Object.keys(sessionStorage);
|
|
247
198
|
sessionStorage.setItem(key, 'not json');
|
|
248
199
|
|
|
249
|
-
|
|
200
|
+
makeBootstrapFetch();
|
|
250
201
|
const result = await makeService().getEnviveConfig();
|
|
251
202
|
|
|
252
203
|
expect(result.org.org.id).toBe('org-1');
|
|
253
204
|
});
|
|
254
205
|
});
|
|
255
206
|
|
|
256
|
-
describe('EnviveConfigService bootstrap
|
|
257
|
-
// The injection bundle supplies env + contextSource
|
|
258
|
-
// is
|
|
207
|
+
describe('EnviveConfigService bootstrap request', () => {
|
|
208
|
+
// The injection bundle supplies env + contextSource, which request bootstrap's
|
|
209
|
+
// url_resolving branch. Without them the branch is omitted (see the config-only
|
|
210
|
+
// case below), but bootstrap is still the config call either way.
|
|
259
211
|
const bootstrapService = () => makeService({ env: 'dev', contextSource: 'app' });
|
|
260
212
|
|
|
261
|
-
it('
|
|
262
|
-
|
|
213
|
+
it('issues a single /v1/session/bootstrap call and returns its config', async () => {
|
|
214
|
+
makeBootstrapFetch('org-boot');
|
|
263
215
|
|
|
264
216
|
const service = bootstrapService();
|
|
265
217
|
const result = await service.getEnviveConfig();
|
|
266
218
|
|
|
267
219
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
268
|
-
expect(mockFetch.mock.calls[0][0]).toContain('/v1/
|
|
269
|
-
expect(result.org.org.id).toBe('org-1');
|
|
270
|
-
expect(service.takeBootstrapUrlResolving()).toBeNull();
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it('calls /v1/session/bootstrap and uses ITS config when the gate is on', async () => {
|
|
274
|
-
makeGateOnConfigFetch();
|
|
275
|
-
makeBootstrapFetch();
|
|
276
|
-
|
|
277
|
-
const service = bootstrapService();
|
|
278
|
-
const result = await service.getEnviveConfig();
|
|
279
|
-
|
|
280
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
281
|
-
expect(mockFetch.mock.calls[1][0]).toContain('/v1/session/bootstrap');
|
|
282
|
-
// config is bootstrap's, not the getWidgetConfig call used to read the gate.
|
|
220
|
+
expect(mockFetch.mock.calls[0][0]).toContain('/v1/session/bootstrap');
|
|
283
221
|
expect(result.org.org.id).toBe('org-boot');
|
|
284
|
-
expect(result.org.org.short_name).toBe('
|
|
222
|
+
expect(result.org.org.short_name).toBe('test-org');
|
|
285
223
|
// gates: snake group_name mapped to camel groupName.
|
|
286
224
|
expect(result.gates).toContainEqual({
|
|
287
225
|
name: 'use_unified_config',
|
|
@@ -300,7 +238,6 @@ describe('EnviveConfigService bootstrap upgrade (use_unified_config)', () => {
|
|
|
300
238
|
});
|
|
301
239
|
|
|
302
240
|
it('stashes bootstrap url_resolving (paired with its URL) for the warm-up to consume once', async () => {
|
|
303
|
-
makeGateOnConfigFetch();
|
|
304
241
|
makeBootstrapFetch();
|
|
305
242
|
|
|
306
243
|
const service = bootstrapService();
|
|
@@ -315,96 +252,73 @@ describe('EnviveConfigService bootstrap upgrade (use_unified_config)', () => {
|
|
|
315
252
|
expect(service.takeBootstrapUrlResolving()).toBeNull();
|
|
316
253
|
});
|
|
317
254
|
|
|
318
|
-
it('sends url_resolving context
|
|
319
|
-
makeGateOnConfigFetch();
|
|
255
|
+
it('sends the url_resolving context (no org identity), gate names, and namespace in the body', async () => {
|
|
320
256
|
makeBootstrapFetch();
|
|
321
257
|
|
|
322
258
|
await bootstrapService().getEnviveConfig();
|
|
323
259
|
|
|
324
|
-
const [, options] = mockFetch.mock.calls[
|
|
260
|
+
const [, options] = mockFetch.mock.calls[0];
|
|
325
261
|
const body = JSON.parse(options.body as string);
|
|
326
262
|
expect(body.include_feature_gates).toContain('use_unified_config');
|
|
327
263
|
// Scopes org_config.configs so bootstrap doesn't dump internal configs to the browser.
|
|
328
264
|
expect(body.namespace).toBe('spiffy-react-components');
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
});
|
|
335
|
-
// The version comes ONLY from the URL query param (none set here) — never from
|
|
336
|
-
// getWidgetConfig's resolution, so bootstrap doesn't depend on the discarded
|
|
337
|
-
// getWidgetConfig response.
|
|
265
|
+
// org identity is NOT sent — the server fills it from the authenticated org.
|
|
266
|
+
expect(body.url_resolving.context).toMatchObject({ source: 'app', env: 'dev' });
|
|
267
|
+
expect(body.url_resolving.context.org_id).toBeUndefined();
|
|
268
|
+
expect(body.url_resolving.context.org_short_name).toBeUndefined();
|
|
269
|
+
// No config-version override set here.
|
|
338
270
|
expect(body.url_resolving.org_base_config_version).toBeUndefined();
|
|
339
271
|
expect(body.url_resolving.override_config_version).toBeUndefined();
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
expect(body.url_resolving.feature_gates.use_unified_config).toBe(true);
|
|
272
|
+
// No client gate overrides set → empty map (server resolves the real gates).
|
|
273
|
+
expect(body.url_resolving.feature_gates).toEqual({});
|
|
343
274
|
// the persistent chat session id is threaded (not an empty string).
|
|
344
275
|
expect(body.url_resolving.context.chat_id).toBeTruthy();
|
|
345
276
|
});
|
|
346
277
|
|
|
347
|
-
it('
|
|
348
|
-
//
|
|
349
|
-
//
|
|
278
|
+
it('forwards ?<gate>=true overrides in the bootstrap feature_gates it sends', async () => {
|
|
279
|
+
// Client overrides (query param) must reach bootstrap's url_resolving so they
|
|
280
|
+
// still drive page-variant selection; the server merges them over its gates.
|
|
350
281
|
window.history.replaceState({}, '', '/?use_unified_config=true&is_empty_div_plp_enabled=true');
|
|
351
|
-
makeOkConfigFetch(); // gates: [] — the ?use_unified_config=true override drives the bootstrap path
|
|
352
282
|
makeBootstrapFetch();
|
|
353
283
|
|
|
354
284
|
await bootstrapService().getEnviveConfig();
|
|
355
285
|
|
|
356
|
-
const body = JSON.parse(mockFetch.mock.calls[
|
|
286
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
|
|
287
|
+
expect(body.url_resolving.feature_gates.use_unified_config).toBe(true);
|
|
357
288
|
expect(body.url_resolving.feature_gates.is_empty_div_plp_enabled).toBe(true);
|
|
358
289
|
});
|
|
359
290
|
|
|
360
|
-
it('
|
|
361
|
-
|
|
291
|
+
it('omits the url_resolving branch for non-bundle callers (no env/contextSource) but still fetches config', async () => {
|
|
292
|
+
makeBootstrapFetch('org-1', null);
|
|
362
293
|
|
|
363
|
-
const
|
|
294
|
+
const service = makeService(); // no env/contextSource
|
|
295
|
+
const result = await service.getEnviveConfig();
|
|
364
296
|
|
|
365
297
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
298
|
+
expect(mockFetch.mock.calls[0][0]).toContain('/v1/session/bootstrap');
|
|
299
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
|
|
300
|
+
expect(body.url_resolving).toBeUndefined();
|
|
366
301
|
expect(result.org.org.id).toBe('org-1');
|
|
302
|
+
expect(service.takeBootstrapUrlResolving()).toBeNull();
|
|
367
303
|
});
|
|
368
304
|
|
|
369
|
-
it('
|
|
370
|
-
makeGateOnConfigFetch();
|
|
305
|
+
it('throws (aborting injection) when bootstrap fails — there is no getWidgetConfig fallback', async () => {
|
|
371
306
|
mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Bad Gateway' });
|
|
372
307
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
377
|
-
// degraded to the proven path rather than aborting.
|
|
378
|
-
expect(result.org.org.id).toBe('org-1');
|
|
379
|
-
expect(service.takeBootstrapUrlResolving()).toBeNull();
|
|
308
|
+
await expect(bootstrapService().getEnviveConfig()).rejects.toThrow(
|
|
309
|
+
'Bootstrap config request failed',
|
|
310
|
+
);
|
|
380
311
|
});
|
|
381
312
|
|
|
382
|
-
it('
|
|
383
|
-
|
|
384
|
-
//
|
|
385
|
-
//
|
|
313
|
+
it('throws when bootstrap returns a malformed 2xx payload', async () => {
|
|
314
|
+
// 2xx but missing required sections — must NOT become a config with empty
|
|
315
|
+
// gates / mock products (which would disable the client session). No fallback
|
|
316
|
+
// left, so the throw propagates and injection aborts.
|
|
386
317
|
mockFetch.mockResolvedValueOnce({
|
|
387
318
|
ok: true,
|
|
388
319
|
json: () => Promise.resolve({ org_config: {}, widget_config: null }),
|
|
389
320
|
});
|
|
390
321
|
|
|
391
|
-
|
|
392
|
-
const result = await service.getEnviveConfig();
|
|
393
|
-
|
|
394
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
395
|
-
expect(result.org.org.id).toBe('org-1'); // getWidgetConfig's, not bootstrap's empty payload
|
|
396
|
-
expect(service.takeBootstrapUrlResolving()).toBeNull();
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
it('honors a ?use_unified_config=true override when the gate itself is off', async () => {
|
|
400
|
-
window.history.replaceState({}, '', '/?use_unified_config=true');
|
|
401
|
-
makeOkConfigFetch(); // gates: [] — the override, not the gate, forces bootstrap
|
|
402
|
-
makeBootstrapFetch();
|
|
403
|
-
|
|
404
|
-
const result = await bootstrapService().getEnviveConfig();
|
|
405
|
-
|
|
406
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
407
|
-
expect(mockFetch.mock.calls[1][0]).toContain('/v1/session/bootstrap');
|
|
408
|
-
expect(result.org.org.id).toBe('org-boot');
|
|
322
|
+
await expect(bootstrapService().getEnviveConfig()).rejects.toThrow('missing required sections');
|
|
409
323
|
});
|
|
410
324
|
});
|