@envive-ai/react-hooks 0.3.38 → 0.3.40

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.
Files changed (119) hide show
  1. package/dist/application/commerce-api.cjs +3 -2
  2. package/dist/application/commerce-api.js +3 -2
  3. package/dist/application/models/api/orgConfigResults.d.cts +13 -2
  4. package/dist/application/models/api/orgConfigResults.d.ts +13 -2
  5. package/dist/application/models/featureGates.cjs +2 -1
  6. package/dist/application/models/featureGates.d.cts +2 -1
  7. package/dist/application/models/featureGates.d.ts +2 -1
  8. package/dist/application/models/featureGates.js +2 -1
  9. package/dist/application/models/graphql/queries/getWidgetConfigQuery.cjs +23 -0
  10. package/dist/application/models/graphql/queries/getWidgetConfigQuery.js +22 -0
  11. package/dist/application/models/index.d.cts +2 -2
  12. package/dist/application/models/index.d.ts +2 -2
  13. package/dist/application/utils/index.cjs +0 -2
  14. package/dist/application/utils/index.d.cts +1 -2
  15. package/dist/application/utils/index.d.ts +1 -2
  16. package/dist/application/utils/index.js +1 -2
  17. package/dist/atoms/app/index.d.cts +7 -7
  18. package/dist/atoms/app/index.d.ts +6 -6
  19. package/dist/atoms/chat/chatState.d.cts +18 -18
  20. package/dist/atoms/chat/chatState.d.ts +19 -19
  21. package/dist/atoms/chat/form.d.cts +2 -2
  22. package/dist/atoms/chat/form.d.ts +2 -2
  23. package/dist/atoms/chat/index.d.cts +2 -2
  24. package/dist/atoms/chat/index.d.ts +3 -3
  25. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  26. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  27. package/dist/atoms/chat/messageQueue.d.cts +6 -6
  28. package/dist/atoms/chat/messageQueue.d.ts +6 -6
  29. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  30. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  31. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  32. package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
  33. package/dist/atoms/chat/replies.d.cts +2 -2
  34. package/dist/atoms/chat/replies.d.ts +2 -2
  35. package/dist/atoms/chat/suggestions.d.cts +2 -2
  36. package/dist/atoms/chat/suggestions.d.ts +2 -2
  37. package/dist/atoms/envive/enviveConfig.d.ts +13 -13
  38. package/dist/atoms/envive/resolvedBaseConfigVersion.cjs +9 -0
  39. package/dist/atoms/envive/resolvedBaseConfigVersion.js +8 -0
  40. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  41. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  42. package/dist/atoms/org/customerService.d.cts +6 -6
  43. package/dist/atoms/org/customerService.d.ts +6 -6
  44. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  45. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  46. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  47. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  48. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  49. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  50. package/dist/atoms/search/chatSearch.d.cts +17 -17
  51. package/dist/atoms/search/chatSearch.d.ts +17 -17
  52. package/dist/atoms/search/searchAPI.d.cts +13 -13
  53. package/dist/atoms/search/searchAPI.d.ts +13 -13
  54. package/dist/atoms/search/types.d.ts +1 -1
  55. package/dist/atoms/search/utils.d.ts +1 -1
  56. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  57. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  58. package/dist/contexts/enviveContext/enviveContext.cjs +11 -3
  59. package/dist/contexts/enviveContext/enviveContext.js +11 -3
  60. package/dist/contexts/graphqlContext/graphqlContext.cjs +1 -1
  61. package/dist/contexts/graphqlContext/graphqlContext.d.cts +3 -1
  62. package/dist/contexts/graphqlContext/graphqlContext.d.ts +3 -1
  63. package/dist/contexts/graphqlContext/graphqlContext.js +1 -1
  64. package/dist/contexts/salesAgentContext/salesAgentService.cjs +9 -3
  65. package/dist/contexts/salesAgentContext/salesAgentService.js +9 -3
  66. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
  67. package/dist/contexts/types.d.cts +1 -1
  68. package/dist/contexts/types.d.ts +1 -1
  69. package/dist/contexts/typesV3.d.cts +1 -1
  70. package/dist/contexts/typesV3.d.ts +1 -1
  71. package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
  72. package/dist/hooks/utils.d.cts +1 -1
  73. package/dist/hooks/utils.d.ts +1 -1
  74. package/dist/services/amplitudeService/amplitudeService.cjs +17 -2
  75. package/dist/services/amplitudeService/amplitudeService.d.cts +3 -1
  76. package/dist/services/amplitudeService/amplitudeService.d.ts +3 -1
  77. package/dist/services/amplitudeService/amplitudeService.js +17 -2
  78. package/dist/services/enviveConfigService/enviveConfigService.cjs +9 -3
  79. package/dist/services/enviveConfigService/enviveConfigService.d.cts +3 -2
  80. package/dist/services/enviveConfigService/enviveConfigService.d.ts +3 -2
  81. package/dist/services/enviveConfigService/enviveConfigService.js +10 -4
  82. package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +78 -39
  83. package/dist/services/enviveConfigService/fetchGraphQLConfig.js +78 -40
  84. package/dist/services/ga4ProjectionService/ga4ProjectionService.cjs +3 -3
  85. package/dist/services/ga4ProjectionService/ga4ProjectionService.js +3 -3
  86. package/dist/services/hardcopyService/hardcopyService.cjs +4 -2
  87. package/dist/services/hardcopyService/hardcopyService.js +4 -2
  88. package/dist/types/config-versions.cjs +3 -1
  89. package/dist/types/config-versions.js +3 -2
  90. package/dist/types/customerService.cjs +2 -1
  91. package/dist/types/customerService.d.cts +2 -1
  92. package/dist/types/customerService.d.ts +2 -1
  93. package/dist/types/customerService.js +2 -1
  94. package/package.json +1 -1
  95. package/src/application/commerce-api.ts +5 -1
  96. package/src/application/models/api/orgConfigResults.ts +27 -0
  97. package/src/application/models/featureGates.ts +5 -0
  98. package/src/application/models/graphql/queries/getWidgetConfigQuery.ts +34 -0
  99. package/src/application/utils/__tests__/elementObserver.test.ts +200 -0
  100. package/src/application/utils/index.ts +0 -1
  101. package/src/atoms/envive/resolvedBaseConfigVersion.ts +11 -0
  102. package/src/contexts/enviveContext/enviveContext.tsx +20 -0
  103. package/src/contexts/graphqlContext/graphqlContext.tsx +5 -0
  104. package/src/contexts/salesAgentContext/salesAgentService.ts +4 -1
  105. package/src/services/amplitudeService/__tests__/amplitudeService.test.ts +95 -0
  106. package/src/services/amplitudeService/amplitudeService.ts +31 -1
  107. package/src/services/enviveConfigService/__tests__/fetchGraphQLConfig.test.ts +107 -1
  108. package/src/services/enviveConfigService/enviveConfigService.ts +35 -7
  109. package/src/services/enviveConfigService/fetchGraphQLConfig.ts +119 -57
  110. package/src/services/ga4ProjectionService/ga4ProjectionService.ts +2 -2
  111. package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +35 -1
  112. package/src/services/hardcopyService/hardcopyService.ts +6 -1
  113. package/src/types/config-versions.ts +8 -0
  114. package/src/types/customerService.ts +1 -0
  115. package/dist/application/utils/merchantUtils.cjs +0 -18
  116. package/dist/application/utils/merchantUtils.d.cts +0 -5
  117. package/dist/application/utils/merchantUtils.d.ts +0 -5
  118. package/dist/application/utils/merchantUtils.js +0 -17
  119. package/src/application/utils/merchantUtils.ts +0 -16
@@ -6,7 +6,6 @@ export * from './divideArray';
6
6
  export * from './domObserver';
7
7
  export * from './elementObserver';
8
8
  export * from './imageFilter';
9
- export * from './merchantUtils';
10
9
  export * from './messageFromFormSubmittedEvent';
11
10
  export * from './messageFromQueryEvent';
12
11
  export * from './messageFromResponse';
@@ -0,0 +1,11 @@
1
+ import { atom } from 'jotai';
2
+
3
+ // The `base_version` pymono resolved for the current user on the unified
4
+ // path (sourced from getWidgetConfig.resolution, not REST). Read by
5
+ // commerce-api, salesAgentService, and hardcopyService as a fallback after
6
+ // the `spiffy_config_version` / `envive_config_version` URL params, so
7
+ // inference requests are scoped to the same version that produced the
8
+ // widgets the user is rendering. Undefined until the unified-path fetch
9
+ // completes, on the legacy path, and when the resolution sentinel
10
+ // `base_version_source === "deployed"` indicates no real pin to forward.
11
+ export const resolvedBaseConfigVersionAtom = atom<string | undefined>(undefined);
@@ -1,7 +1,9 @@
1
1
  import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useSetAtom } from 'jotai';
2
3
 
3
4
  import { AmplitudeService } from 'src/services/amplitudeService/amplitudeService';
4
5
  import { EnviveServiceConfig, IEnviveConfigService } from 'src/services/enviveConfigService';
6
+ import { resolvedBaseConfigVersionAtom } from 'src/atoms/envive/resolvedBaseConfigVersion';
5
7
  import { IHardcopyService } from 'src/services/hardcopyService';
6
8
  import { IUserIdentityService } from 'src/services/userIdentityService';
7
9
  import Logger from 'src/application/logging/logger';
@@ -102,6 +104,7 @@ export const EnviveProvider: React.FC<EnviveProviderProps> = ({
102
104
  }) => {
103
105
  const { extensionService } = services;
104
106
  const [enviveServiceConfig, setEnviveServiceConfig] = useState<EnviveServiceConfig | null>(null);
107
+ const setResolvedBaseConfigVersion = useSetAtom(resolvedBaseConfigVersionAtom);
105
108
  const didReportSuccessRef = useRef(false);
106
109
 
107
110
  const invokeExtensionCallback = useCallback((callback?: () => Promise<unknown> | void) => {
@@ -128,10 +131,26 @@ export const EnviveProvider: React.FC<EnviveProviderProps> = ({
128
131
  useEffect(() => {
129
132
  invokeExtensionCallback(extensionService?.onInjectionLoading);
130
133
 
134
+ // Clear any prior merchant's resolved version before the new fetch — the
135
+ // atom is module-level and survives EnviveProvider remounts (e.g. when
136
+ // Envive Hub switches merchants). Without the reset, the previous
137
+ // merchant's `baseVersion` would leak into the new merchant's inference
138
+ // requests when the new resolution falls back to the "deployed" sentinel
139
+ // and the conditional write below is skipped.
140
+ setResolvedBaseConfigVersion(undefined);
141
+
131
142
  const fetchEnviveConfig = async () => {
132
143
  try {
133
144
  const config = await services.enviveConfigService.getEnviveConfig();
134
145
  setEnviveServiceConfig(config);
146
+ // Forward only real pins to inference. baseVersionSource === "deployed"
147
+ // is the sentinel for "no merchant_layer, no override" — leaving the
148
+ // atom undefined lets inference fall through to backend's default
149
+ // version selection instead of pinning to the literal string "deployed".
150
+ const { baseVersion, baseVersionSource } = config.orgConfig?.resolution ?? {};
151
+ if (baseVersion && baseVersionSource && baseVersionSource !== 'deployed') {
152
+ setResolvedBaseConfigVersion(baseVersion);
153
+ }
135
154
  } catch (error) {
136
155
  const resolvedError =
137
156
  error instanceof Error ? error : new Error('Failed to load envive config');
@@ -146,6 +165,7 @@ export const EnviveProvider: React.FC<EnviveProviderProps> = ({
146
165
  extensionService,
147
166
  invokeExtensionCallback,
148
167
  invokeExtensionErrorCallback,
168
+ setResolvedBaseConfigVersion,
149
169
  ]);
150
170
 
151
171
  useEffect(() => {
@@ -4,6 +4,7 @@ import { baseUrlAtom, orgLevelApiKeyAtom } from 'src/atoms/envive/enviveConfig';
4
4
  import { ColorMapping } from 'src/application/models/colorsConfig';
5
5
  import { FrontendConfig } from 'src/application/models/frontendConfig';
6
6
  import { CamelCasedPropertiesDeep } from 'src/application/models';
7
+ import { ExperimentConfigResolutionMetadata } from 'src/application/models/api/orgConfigResults';
7
8
  import { FrontendConfigV3 } from 'src/application/models/frontendConfigV3';
8
9
  import { ColorMappingV3 } from 'src/application/models/colorsConfigV3';
9
10
  import { OrgPageConfig } from '../types';
@@ -17,6 +18,10 @@ export type GraphQlConfigValues = {
17
18
  colorsConfig?: ColorsConfigResponse | ColorsConfigV3Response;
18
19
  frontendConfig?: CamelCasedPropertiesDeep<FrontendConfig | FrontendConfigV3>;
19
20
  orgPageConfig?: OrgPageConfig;
21
+ // Populated only by the unified-path fetcher (getWidgetConfig.resolution).
22
+ // Legacy getColorsAndFrontendQuery does not return it. Read by EnviveProvider
23
+ // to thread baseVersion into inference requests as override_config_version.
24
+ resolution?: ExperimentConfigResolutionMetadata;
20
25
  };
21
26
 
22
27
  interface GraphQLContextValue {
@@ -15,6 +15,7 @@ import {
15
15
  import { messageFromResponse } from 'src/application/utils';
16
16
  import { appDetailsAtom } from 'src/atoms/app';
17
17
  import { analyticsContextAtom } from 'src/atoms/app/variant';
18
+ import { resolvedBaseConfigVersionAtom } from 'src/atoms/envive/resolvedBaseConfigVersion';
18
19
  import {
19
20
  messagesAtom,
20
21
  pendingResponseAtom,
@@ -247,6 +248,7 @@ export const useSalesAgentService: () => SalesAgentService = () => {
247
248
 
248
249
  const featureFlagService = useFeatureFlagService();
249
250
  const context = useAtomValue(appDetailsAtom);
251
+ const resolvedBaseConfigVersion = useAtomValue(resolvedBaseConfigVersionAtom);
250
252
 
251
253
  const createResponsePayload = useCallback(
252
254
  ({
@@ -261,6 +263,7 @@ export const useSalesAgentService: () => SalesAgentService = () => {
261
263
  const overrideConfigVersion =
262
264
  getQueryParam('spiffy_config_version') ||
263
265
  getQueryParam('envive_config_version') ||
266
+ resolvedBaseConfigVersion ||
264
267
  undefined;
265
268
  const overrideModelDatetime = getQueryParam('override_model_datetime') || undefined;
266
269
  return {
@@ -273,7 +276,7 @@ export const useSalesAgentService: () => SalesAgentService = () => {
273
276
  overrideModelDatetime,
274
277
  };
275
278
  },
276
- [context, featureFlagService?.featureFlagService],
279
+ [context, featureFlagService?.featureFlagService, resolvedBaseConfigVersion],
277
280
  );
278
281
 
279
282
  const getStreamingResponses = useCallback(
@@ -698,4 +698,99 @@ describe('AmplitudeService', () => {
698
698
  });
699
699
  });
700
700
  });
701
+
702
+ describe('Experiment-assignment props', () => {
703
+ it('emits experiment.{ns}.* keys as null when no assignments are configured', async () => {
704
+ const service = createService();
705
+ await service.trackEvent({
706
+ eventName: SpiffyMetricsEventName.ChatUserMessageInput,
707
+ eventProps: {},
708
+ });
709
+ const props = mockTrack.mock.calls[0]?.[1] ?? {};
710
+ expect(props['experiment.ml.layer_name']).toBeNull();
711
+ expect(props['experiment.ml.experiment_name']).toBeNull();
712
+ expect(props['experiment.ml.group_name']).toBeNull();
713
+ expect(props['experiment.ml.rule_id']).toBeNull();
714
+ expect(props['experiment.merchant.layer_name']).toBeNull();
715
+ expect(props['experiment.merchant.experiment_name']).toBeNull();
716
+ expect(props['experiment.merchant.group_name']).toBeNull();
717
+ expect(props['experiment.merchant.rule_id']).toBeNull();
718
+ });
719
+
720
+ it('populates experiment.{ns}.* keys when assignments are passed at construction', async () => {
721
+ const service = createService({
722
+ experimentAssignments: [
723
+ {
724
+ layer_name: 'ml_test_store',
725
+ namespace: 'ml',
726
+ allocated_experiment_name: 'automated_testing_ml_experiment',
727
+ group_name: 'ML Config Variation',
728
+ },
729
+ {
730
+ layer_name: 'merchant_test_store',
731
+ namespace: 'merchant',
732
+ allocated_experiment_name: 'automated_testing_merchant_experiment',
733
+ group_name: 'Merchant Config Variation',
734
+ },
735
+ ],
736
+ });
737
+ await service.trackEvent({
738
+ eventName: SpiffyMetricsEventName.ChatUserMessageInput,
739
+ eventProps: {},
740
+ });
741
+ const props = mockTrack.mock.calls[0]?.[1] ?? {};
742
+ expect(props['experiment.ml.layer_name']).toBe('ml_test_store');
743
+ expect(props['experiment.ml.experiment_name']).toBe('automated_testing_ml_experiment');
744
+ expect(props['experiment.ml.group_name']).toBe('ML Config Variation');
745
+ expect(props['experiment.merchant.layer_name']).toBe('merchant_test_store');
746
+ expect(props['experiment.merchant.experiment_name']).toBe(
747
+ 'automated_testing_merchant_experiment',
748
+ );
749
+ expect(props['experiment.merchant.group_name']).toBe('Merchant Config Variation');
750
+ // rule_id reserved — backend doesn't surface it.
751
+ expect(props['experiment.ml.rule_id']).toBeNull();
752
+ expect(props['experiment.merchant.rule_id']).toBeNull();
753
+ });
754
+
755
+ it('emits the unassigned namespace as null when only one is assigned', async () => {
756
+ const service = createService({
757
+ experimentAssignments: [
758
+ {
759
+ layer_name: 'ml_test_store',
760
+ namespace: 'ml',
761
+ allocated_experiment_name: 'ml_only',
762
+ group_name: 'ML Only',
763
+ },
764
+ ],
765
+ });
766
+ await service.trackEvent({
767
+ eventName: SpiffyMetricsEventName.ChatUserMessageInput,
768
+ eventProps: {},
769
+ });
770
+ const props = mockTrack.mock.calls[0]?.[1] ?? {};
771
+ expect(props['experiment.ml.layer_name']).toBe('ml_test_store');
772
+ expect(props['experiment.merchant.layer_name']).toBeNull();
773
+ });
774
+
775
+ it('drops malformed assignments missing layer_name or namespace', async () => {
776
+ const service = createService({
777
+ experimentAssignments: [
778
+ // Missing layer_name → dropped silently. (Belt-and-braces: pymono's
779
+ // tests enforce the wire shape, but if a field ever goes null we'd
780
+ // rather emit `null` props than crash.)
781
+ {
782
+ namespace: 'ml',
783
+ allocated_experiment_name: 'orphan',
784
+ },
785
+ ],
786
+ });
787
+ await service.trackEvent({
788
+ eventName: SpiffyMetricsEventName.ChatUserMessageInput,
789
+ eventProps: {},
790
+ });
791
+ const props = mockTrack.mock.calls[0]?.[1] ?? {};
792
+ expect(props['experiment.ml.layer_name']).toBeNull();
793
+ expect(props['experiment.ml.experiment_name']).toBeNull();
794
+ });
795
+ });
701
796
  });
@@ -11,6 +11,7 @@ import type {
11
11
  import { FeatureFlagService } from 'src/contexts/featureFlagServiceContext/featureFlagServiceContext';
12
12
  import { WidgetTypeV3 } from 'src/contexts/typesV3';
13
13
  import { ChatElementDisplayLocationV3 } from 'src/application/models/chatElementDisplayLocationV3';
14
+ import { ExperimentNamespace, OrgConfigExperimentAssignment } from 'src/application/models';
14
15
  import { projectToGA4 } from '../ga4ProjectionService/ga4ProjectionService';
15
16
  import { EnviveMetricsEventName, SpiffyMetricsEventName } from './eventNames';
16
17
 
@@ -39,8 +40,37 @@ export interface AmplitudeServiceConfig {
39
40
  enviveOn: boolean;
40
41
  enabledFeatures?: Record<string, boolean>;
41
42
  getLocalStorageItem: null | ((key: string) => string | null);
43
+ // Captured at construction. Drives `experiment.{ns}.*` event-property
44
+ // enrichment. Sourced from /v1/org/config when the `use_unified_config`
45
+ // gate is on; otherwise omitted (props emit as null).
46
+ experimentAssignments?: OrgConfigExperimentAssignment[];
42
47
  }
43
48
 
49
+ // Namespaces emitted on every event with constant keys, so the BigQuery
50
+ // schema is stable regardless of which (if any) experiment is running.
51
+ // Currently `ml` + `merchant`; matches `ExperimentNamespace`.
52
+ const EXPERIMENT_NAMESPACES: ExperimentNamespace[] = ['ml', 'merchant'];
53
+
54
+ const buildExperimentProps = (
55
+ assignments: OrgConfigExperimentAssignment[] | undefined,
56
+ ): Record<string, unknown> => {
57
+ const byNamespace = new Map<ExperimentNamespace, OrgConfigExperimentAssignment>();
58
+ (assignments ?? []).forEach(a => {
59
+ if (a.layer_name != null && a.namespace != null) {
60
+ byNamespace.set(a.namespace, a);
61
+ }
62
+ });
63
+ return EXPERIMENT_NAMESPACES.reduce<Record<string, unknown>>((acc, ns) => {
64
+ const a = byNamespace.get(ns);
65
+ acc[`experiment.${ns}.layer_name`] = a?.layer_name ?? null;
66
+ acc[`experiment.${ns}.experiment_name`] = a?.allocated_experiment_name ?? null;
67
+ acc[`experiment.${ns}.group_name`] = a?.group_name ?? null;
68
+ // `rule_id` reserved — backend doesn't currently surface it.
69
+ acc[`experiment.${ns}.rule_id`] = null;
70
+ return acc;
71
+ }, {});
72
+ };
73
+
44
74
  export class AmplitudeService {
45
75
  private amplitudeClient: BrowserClient | undefined;
46
76
 
@@ -91,7 +121,7 @@ export class AmplitudeService {
91
121
  return acc;
92
122
  }, {})
93
123
  : {};
94
- const experimentProps = {}; // No direct equivalent for experiments in EnviveConfig yet
124
+ const experimentProps = buildExperimentProps(this.config.experimentAssignments);
95
125
 
96
126
  const environmentProps = {
97
127
  'environment.execution_context': 'bundle',
@@ -1,6 +1,10 @@
1
- import { fetchGraphQLConfig, DEFAULT_PAGE_VARIANTS } from '../fetchGraphQLConfig';
2
1
  import { mockV3ColorsConfig, mockV3FrontendConfig } from 'src/contexts/graphqlContext/mockV3Config';
3
2
  import { FloatingButtonLocation } from '@envive-ai/react-toolkit-v3/FloatingButton';
3
+ import {
4
+ DEFAULT_PAGE_VARIANTS,
5
+ fetchGraphQLConfig,
6
+ fetchUnifiedGraphQLConfig,
7
+ } from '../fetchGraphQLConfig';
4
8
 
5
9
  const { MockLogger } = vi.hoisted(() => {
6
10
  const MockLogger = vi.fn(function () {
@@ -423,3 +427,105 @@ describe('fetchGraphQLConfig', () => {
423
427
  });
424
428
  });
425
429
  });
430
+
431
+ describe('fetchUnifiedGraphQLConfig', () => {
432
+ function makeUnifiedOkFetch(v3RootConfigValues: unknown, resolution?: unknown) {
433
+ mockFetch.mockResolvedValueOnce({
434
+ ok: true,
435
+ json: () =>
436
+ Promise.resolve({
437
+ data: {
438
+ me: {
439
+ getWidgetConfig: {
440
+ productsConfig: {
441
+ v_three_config: { values: v3RootConfigValues },
442
+ },
443
+ resolution,
444
+ },
445
+ },
446
+ },
447
+ }),
448
+ });
449
+ }
450
+
451
+ it('issues a getWidgetConfig query (not getProductsConfigByVersion) and forwards userId', async () => {
452
+ makeUnifiedOkFetch(null);
453
+
454
+ await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
455
+
456
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
457
+ expect(body.query).toContain('getWidgetConfig');
458
+ expect(body.query).not.toContain('getProductsConfigByVersion');
459
+ // userId is required by the schema; omitting it 400s. Regression guard.
460
+ // version is absent without a URL-param override so the resolver uses
461
+ // Statsig bucketing (passing 'deployed' here would bypass Statsig).
462
+ expect(body.variables).toEqual({ userId: 'user_1' });
463
+ });
464
+
465
+ it('threads a spiffy_config_version URL param through as the version variable', async () => {
466
+ window.history.replaceState({}, '', '/?spiffy_config_version=1.2.3');
467
+ try {
468
+ makeUnifiedOkFetch(null);
469
+
470
+ await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
471
+
472
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
473
+ expect(body.variables).toEqual({ userId: 'user_1', version: '1.2.3' });
474
+ } finally {
475
+ window.history.replaceState({}, '', '/');
476
+ }
477
+ });
478
+
479
+ it('parses productsConfig into the same shape as fetchGraphQLConfig', async () => {
480
+ makeUnifiedOkFetch(baseV3Config);
481
+
482
+ const result = await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
483
+
484
+ expect(result.colorsConfig).toBeDefined();
485
+ expect(result.frontendConfig).toBeDefined();
486
+ expect(result.orgPageConfig).toBeDefined();
487
+ });
488
+
489
+ it('returns resolution metadata alongside the config so callers can thread baseVersion into inference', async () => {
490
+ makeUnifiedOkFetch(baseV3Config, {
491
+ baseVersion: '1.0.670',
492
+ baseVersionSource: 'merchant_layer',
493
+ });
494
+
495
+ const result = await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
496
+
497
+ expect(result.resolution).toEqual({
498
+ baseVersion: '1.0.670',
499
+ baseVersionSource: 'merchant_layer',
500
+ });
501
+ });
502
+
503
+ it('selects resolution fields in the query so the server can fill them', async () => {
504
+ makeUnifiedOkFetch(null);
505
+
506
+ await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
507
+
508
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
509
+ expect(body.query).toContain('resolution');
510
+ expect(body.query).toContain('baseVersion');
511
+ expect(body.query).toContain('baseVersionSource');
512
+ });
513
+
514
+ it('falls back to DEFAULT_PAGE_VARIANTS when productsConfig has no page_variants array', async () => {
515
+ // Reuses the no-colors fallback path: when productsConfig is missing
516
+ // colors, DEFAULT_PAGE_VARIANTS is returned as the orgPageConfig.
517
+ makeUnifiedOkFetch(null);
518
+
519
+ const result = await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
520
+
521
+ expect(result.orgPageConfig?.pageVariants).toEqual(DEFAULT_PAGE_VARIANTS);
522
+ });
523
+
524
+ it('returns empty config on network failure', async () => {
525
+ mockFetch.mockRejectedValueOnce(new Error('boom'));
526
+
527
+ const result = await fetchUnifiedGraphQLConfig('https://api.example.com', 'key', 'user_1');
528
+
529
+ expect(result).toEqual({ colorsConfig: undefined, frontendConfig: undefined });
530
+ });
531
+ });
@@ -1,7 +1,8 @@
1
- import { OrgConfigFeatureGate } from 'src/application/models';
1
+ import { OrgConfigExperimentAssignment, OrgConfigFeatureGate } from 'src/application/models';
2
2
  import { FeatureGates } from 'src/application/models/featureGates';
3
+ import { FeatureFlagService } from 'src/contexts/featureFlagServiceContext';
3
4
  import { GraphQlConfigValues } from 'src/contexts/graphqlContext';
4
- import { fetchGraphQLConfig } from './fetchGraphQLConfig';
5
+ import { fetchGraphQLConfig, fetchUnifiedGraphQLConfig } from './fetchGraphQLConfig';
5
6
 
6
7
  type EnviveConfigServiceProps = {
7
8
  baseUrl: string;
@@ -24,6 +25,14 @@ export type EnviveServiceConfig = {
24
25
  };
25
26
  };
26
27
  gates: OrgConfigFeatureGate[];
28
+ // Populated by the backend when the `use_unified_config` Statsig gate
29
+ // evaluates true. amplitudeService reads these to enrich events with
30
+ // `experiment.{ns}.*` properties.
31
+ experiment_assignments?: OrgConfigExperimentAssignment[];
32
+ // orgConfig.resolution is populated only on the unified path (sourced from
33
+ // getWidgetConfig.resolution, not /v1/org/config.resolution) so the version
34
+ // threaded into inference requests matches the version that produced the
35
+ // rendered widgets.
27
36
  orgConfig?: GraphQlConfigValues;
28
37
  };
29
38
 
@@ -67,16 +76,35 @@ export class EnviveConfigService implements IEnviveConfigService {
67
76
  .map(featureGate => featureGate.toString())
68
77
  .join(','),
69
78
  });
70
- const [orgConfigResponse, orgConfig] = await Promise.all([
71
- fetch(`${this.baseUrl}/v1/org/config?${queryParams.toString()}`, {
79
+ // Serialized so we can branch the GraphQL call on the `use_unified_config`
80
+ // gate returned by /v1/org/config. Once the migration to getWidgetConfig
81
+ // completes (one endpoint returning gates + experiments + productsConfig),
82
+ // this collapses back to a single call.
83
+ const orgConfigResponse = await fetch(
84
+ `${this.baseUrl}/v1/org/config?${queryParams.toString()}`,
85
+ {
72
86
  method: 'GET',
73
87
  headers: {
74
88
  'Content-Type': 'application/json',
75
89
  Authorization: `Bearer ${this.apiKey}`,
76
90
  },
77
- }).then(r => r.json() as Promise<EnviveServiceConfig>),
78
- fetchGraphQLConfig(this.baseUrl, this.apiKey),
79
- ]);
91
+ },
92
+ ).then(async r => {
93
+ if (!r.ok) {
94
+ throw new Error(`Org config request failed with status ${r.status}`);
95
+ }
96
+ return r.json() as Promise<EnviveServiceConfig>;
97
+ });
98
+
99
+ // Honor URL / `window._spiffy` / localStorage overrides for
100
+ // `use_unified_config` the same way every other feature gate is read.
101
+ const useUnifiedConfig = new FeatureFlagService(
102
+ orgConfigResponse.gates ?? [],
103
+ ).isFeatureGateEnabled(FeatureGates.UseUnifiedConfig);
104
+ const orgConfig = useUnifiedConfig
105
+ ? await fetchUnifiedGraphQLConfig(this.baseUrl, this.apiKey, this.userId)
106
+ : await fetchGraphQLConfig(this.baseUrl, this.apiKey);
107
+
80
108
  this.response = { ...orgConfigResponse, orgConfig };
81
109
  return this.response;
82
110
  }