@thoughtspot/visual-embed-sdk 1.52.2-beta.2 → 1.53.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.
Files changed (237) hide show
  1. package/cjs/package.json +1 -1
  2. package/cjs/src/css-variables.d.ts +373 -5
  3. package/cjs/src/css-variables.d.ts.map +1 -1
  4. package/cjs/src/embed/app.d.ts +14 -0
  5. package/cjs/src/embed/app.d.ts.map +1 -1
  6. package/cjs/src/embed/app.js +6 -3
  7. package/cjs/src/embed/app.js.map +1 -1
  8. package/cjs/src/embed/app.spec.js +28 -0
  9. package/cjs/src/embed/app.spec.js.map +1 -1
  10. package/cjs/src/embed/auto-frame-renderer.d.ts.map +1 -1
  11. package/cjs/src/embed/auto-frame-renderer.js +244 -4
  12. package/cjs/src/embed/auto-frame-renderer.js.map +1 -1
  13. package/cjs/src/embed/auto-frame-renderer.spec.js +389 -0
  14. package/cjs/src/embed/auto-frame-renderer.spec.js.map +1 -1
  15. package/cjs/src/embed/bodyless-conversation.js +1 -1
  16. package/cjs/src/embed/bodyless-conversation.js.map +1 -1
  17. package/cjs/src/embed/conversation.d.ts +15 -0
  18. package/cjs/src/embed/conversation.d.ts.map +1 -1
  19. package/cjs/src/embed/conversation.js +7 -3
  20. package/cjs/src/embed/conversation.js.map +1 -1
  21. package/cjs/src/embed/conversation.spec.js +66 -0
  22. package/cjs/src/embed/conversation.spec.js.map +1 -1
  23. package/cjs/src/embed/liveboard-override-utils.d.ts +59 -0
  24. package/cjs/src/embed/liveboard-override-utils.d.ts.map +1 -0
  25. package/cjs/src/embed/liveboard-override-utils.js +19 -0
  26. package/cjs/src/embed/liveboard-override-utils.js.map +1 -0
  27. package/cjs/src/embed/liveboard-override-utils.spec.d.ts +2 -0
  28. package/cjs/src/embed/liveboard-override-utils.spec.d.ts.map +1 -0
  29. package/cjs/src/embed/liveboard-override-utils.spec.js +34 -0
  30. package/cjs/src/embed/liveboard-override-utils.spec.js.map +1 -0
  31. package/cjs/src/embed/liveboard.d.ts +58 -13
  32. package/cjs/src/embed/liveboard.d.ts.map +1 -1
  33. package/cjs/src/embed/liveboard.js +11 -34
  34. package/cjs/src/embed/liveboard.js.map +1 -1
  35. package/cjs/src/embed/liveboard.spec.js +104 -79
  36. package/cjs/src/embed/liveboard.spec.js.map +1 -1
  37. package/cjs/src/embed/search-bar.js +1 -1
  38. package/cjs/src/embed/search-bar.js.map +1 -1
  39. package/cjs/src/embed/search.js +1 -1
  40. package/cjs/src/embed/search.js.map +1 -1
  41. package/cjs/src/embed/search.spec.js +20 -0
  42. package/cjs/src/embed/search.spec.js.map +1 -1
  43. package/cjs/src/embed/spotter-utils.d.ts +11 -0
  44. package/cjs/src/embed/spotter-utils.d.ts.map +1 -1
  45. package/cjs/src/embed/spotter-utils.js +13 -1
  46. package/cjs/src/embed/spotter-utils.js.map +1 -1
  47. package/cjs/src/embed/spotter-viz-utils.d.ts +15 -0
  48. package/cjs/src/embed/spotter-viz-utils.d.ts.map +1 -1
  49. package/cjs/src/embed/spotter-viz-utils.js.map +1 -1
  50. package/cjs/src/embed/spotter-viz-utils.spec.js +11 -0
  51. package/cjs/src/embed/spotter-viz-utils.spec.js.map +1 -1
  52. package/cjs/src/embed/ts-embed.d.ts +27 -34
  53. package/cjs/src/embed/ts-embed.d.ts.map +1 -1
  54. package/cjs/src/embed/ts-embed.js +224 -102
  55. package/cjs/src/embed/ts-embed.js.map +1 -1
  56. package/cjs/src/embed/ts-embed.spec.d.ts.map +1 -1
  57. package/cjs/src/embed/ts-embed.spec.js +543 -166
  58. package/cjs/src/embed/ts-embed.spec.js.map +1 -1
  59. package/cjs/src/index.d.ts +4 -2
  60. package/cjs/src/index.d.ts.map +1 -1
  61. package/cjs/src/index.js +4 -2
  62. package/cjs/src/index.js.map +1 -1
  63. package/cjs/src/mixpanel-service.d.ts +1 -0
  64. package/cjs/src/mixpanel-service.d.ts.map +1 -1
  65. package/cjs/src/mixpanel-service.js +1 -0
  66. package/cjs/src/mixpanel-service.js.map +1 -1
  67. package/cjs/src/react/all-types-export.d.ts +1 -1
  68. package/cjs/src/react/all-types-export.d.ts.map +1 -1
  69. package/cjs/src/react/all-types-export.js +2 -1
  70. package/cjs/src/react/all-types-export.js.map +1 -1
  71. package/cjs/src/types.d.ts +182 -2
  72. package/cjs/src/types.d.ts.map +1 -1
  73. package/cjs/src/types.js +109 -0
  74. package/cjs/src/types.js.map +1 -1
  75. package/cjs/src/utils/graphql/answerService/answer-queries.d.ts +1 -0
  76. package/cjs/src/utils/graphql/answerService/answer-queries.d.ts.map +1 -1
  77. package/cjs/src/utils/graphql/answerService/answer-queries.js +8 -1
  78. package/cjs/src/utils/graphql/answerService/answer-queries.js.map +1 -1
  79. package/cjs/src/utils/graphql/answerService/answerService.d.ts +28 -4
  80. package/cjs/src/utils/graphql/answerService/answerService.d.ts.map +1 -1
  81. package/cjs/src/utils/graphql/answerService/answerService.js +39 -4
  82. package/cjs/src/utils/graphql/answerService/answerService.js.map +1 -1
  83. package/cjs/src/utils/graphql/answerService/answerService.spec.js +124 -0
  84. package/cjs/src/utils/graphql/answerService/answerService.spec.js.map +1 -1
  85. package/dist/{index-CnHREfvN.js → index-CyDR8j_0.js} +1 -1
  86. package/dist/src/css-variables.d.ts +373 -5
  87. package/dist/src/css-variables.d.ts.map +1 -1
  88. package/dist/src/embed/app.d.ts +14 -0
  89. package/dist/src/embed/app.d.ts.map +1 -1
  90. package/dist/src/embed/auto-frame-renderer.d.ts.map +1 -1
  91. package/dist/src/embed/conversation.d.ts +15 -0
  92. package/dist/src/embed/conversation.d.ts.map +1 -1
  93. package/dist/src/embed/liveboard-override-utils.d.ts +59 -0
  94. package/dist/src/embed/liveboard-override-utils.d.ts.map +1 -0
  95. package/dist/src/embed/liveboard-override-utils.spec.d.ts +2 -0
  96. package/dist/src/embed/liveboard-override-utils.spec.d.ts.map +1 -0
  97. package/dist/src/embed/liveboard.d.ts +58 -13
  98. package/dist/src/embed/liveboard.d.ts.map +1 -1
  99. package/dist/src/embed/spotter-utils.d.ts +11 -0
  100. package/dist/src/embed/spotter-utils.d.ts.map +1 -1
  101. package/dist/src/embed/spotter-viz-utils.d.ts +15 -0
  102. package/dist/src/embed/spotter-viz-utils.d.ts.map +1 -1
  103. package/dist/src/embed/ts-embed.d.ts +27 -34
  104. package/dist/src/embed/ts-embed.d.ts.map +1 -1
  105. package/dist/src/embed/ts-embed.spec.d.ts.map +1 -1
  106. package/dist/src/index.d.ts +4 -2
  107. package/dist/src/index.d.ts.map +1 -1
  108. package/dist/src/mixpanel-service.d.ts +1 -0
  109. package/dist/src/mixpanel-service.d.ts.map +1 -1
  110. package/dist/src/react/all-types-export.d.ts +1 -1
  111. package/dist/src/react/all-types-export.d.ts.map +1 -1
  112. package/dist/src/types.d.ts +182 -2
  113. package/dist/src/types.d.ts.map +1 -1
  114. package/dist/src/utils/graphql/answerService/answer-queries.d.ts +1 -0
  115. package/dist/src/utils/graphql/answerService/answer-queries.d.ts.map +1 -1
  116. package/dist/src/utils/graphql/answerService/answerService.d.ts +28 -4
  117. package/dist/src/utils/graphql/answerService/answerService.d.ts.map +1 -1
  118. package/dist/tsembed-react.es.js +437 -191
  119. package/dist/tsembed-react.js +436 -190
  120. package/dist/tsembed.es.js +680 -196
  121. package/dist/tsembed.js +678 -194
  122. package/dist/visual-embed-sdk-react-full.d.ts +702 -56
  123. package/dist/visual-embed-sdk-react.d.ts +697 -56
  124. package/dist/visual-embed-sdk.d.ts +744 -57
  125. package/lib/package.json +1 -1
  126. package/lib/src/css-variables.d.ts +373 -5
  127. package/lib/src/css-variables.d.ts.map +1 -1
  128. package/lib/src/embed/app.d.ts +14 -0
  129. package/lib/src/embed/app.d.ts.map +1 -1
  130. package/lib/src/embed/app.js +7 -4
  131. package/lib/src/embed/app.js.map +1 -1
  132. package/lib/src/embed/app.spec.js +28 -0
  133. package/lib/src/embed/app.spec.js.map +1 -1
  134. package/lib/src/embed/auto-frame-renderer.d.ts.map +1 -1
  135. package/lib/src/embed/auto-frame-renderer.js +244 -4
  136. package/lib/src/embed/auto-frame-renderer.js.map +1 -1
  137. package/lib/src/embed/auto-frame-renderer.spec.js +389 -0
  138. package/lib/src/embed/auto-frame-renderer.spec.js.map +1 -1
  139. package/lib/src/embed/bodyless-conversation.js +1 -1
  140. package/lib/src/embed/bodyless-conversation.js.map +1 -1
  141. package/lib/src/embed/conversation.d.ts +15 -0
  142. package/lib/src/embed/conversation.d.ts.map +1 -1
  143. package/lib/src/embed/conversation.js +8 -4
  144. package/lib/src/embed/conversation.js.map +1 -1
  145. package/lib/src/embed/conversation.spec.js +67 -1
  146. package/lib/src/embed/conversation.spec.js.map +1 -1
  147. package/lib/src/embed/liveboard-override-utils.d.ts +59 -0
  148. package/lib/src/embed/liveboard-override-utils.d.ts.map +1 -0
  149. package/lib/src/embed/liveboard-override-utils.js +15 -0
  150. package/lib/src/embed/liveboard-override-utils.js.map +1 -0
  151. package/lib/src/embed/liveboard-override-utils.spec.d.ts +2 -0
  152. package/lib/src/embed/liveboard-override-utils.spec.d.ts.map +1 -0
  153. package/lib/src/embed/liveboard-override-utils.spec.js +32 -0
  154. package/lib/src/embed/liveboard-override-utils.spec.js.map +1 -0
  155. package/lib/src/embed/liveboard.d.ts +58 -13
  156. package/lib/src/embed/liveboard.d.ts.map +1 -1
  157. package/lib/src/embed/liveboard.js +11 -33
  158. package/lib/src/embed/liveboard.js.map +1 -1
  159. package/lib/src/embed/liveboard.spec.js +104 -79
  160. package/lib/src/embed/liveboard.spec.js.map +1 -1
  161. package/lib/src/embed/search-bar.js +1 -1
  162. package/lib/src/embed/search-bar.js.map +1 -1
  163. package/lib/src/embed/search.js +1 -1
  164. package/lib/src/embed/search.js.map +1 -1
  165. package/lib/src/embed/search.spec.js +20 -0
  166. package/lib/src/embed/search.spec.js.map +1 -1
  167. package/lib/src/embed/spotter-utils.d.ts +11 -0
  168. package/lib/src/embed/spotter-utils.d.ts.map +1 -1
  169. package/lib/src/embed/spotter-utils.js +12 -0
  170. package/lib/src/embed/spotter-utils.js.map +1 -1
  171. package/lib/src/embed/spotter-viz-utils.d.ts +15 -0
  172. package/lib/src/embed/spotter-viz-utils.d.ts.map +1 -1
  173. package/lib/src/embed/spotter-viz-utils.js.map +1 -1
  174. package/lib/src/embed/spotter-viz-utils.spec.js +11 -0
  175. package/lib/src/embed/spotter-viz-utils.spec.js.map +1 -1
  176. package/lib/src/embed/ts-embed.d.ts +27 -34
  177. package/lib/src/embed/ts-embed.d.ts.map +1 -1
  178. package/lib/src/embed/ts-embed.js +224 -102
  179. package/lib/src/embed/ts-embed.js.map +1 -1
  180. package/lib/src/embed/ts-embed.spec.d.ts.map +1 -1
  181. package/lib/src/embed/ts-embed.spec.js +544 -167
  182. package/lib/src/embed/ts-embed.spec.js.map +1 -1
  183. package/lib/src/index.d.ts +4 -2
  184. package/lib/src/index.d.ts.map +1 -1
  185. package/lib/src/index.js +3 -2
  186. package/lib/src/index.js.map +1 -1
  187. package/lib/src/mixpanel-service.d.ts +1 -0
  188. package/lib/src/mixpanel-service.d.ts.map +1 -1
  189. package/lib/src/mixpanel-service.js +1 -0
  190. package/lib/src/mixpanel-service.js.map +1 -1
  191. package/lib/src/react/all-types-export.d.ts +1 -1
  192. package/lib/src/react/all-types-export.d.ts.map +1 -1
  193. package/lib/src/react/all-types-export.js +1 -1
  194. package/lib/src/react/all-types-export.js.map +1 -1
  195. package/lib/src/types.d.ts +182 -2
  196. package/lib/src/types.d.ts.map +1 -1
  197. package/lib/src/types.js +109 -0
  198. package/lib/src/types.js.map +1 -1
  199. package/lib/src/utils/graphql/answerService/answer-queries.d.ts +1 -0
  200. package/lib/src/utils/graphql/answerService/answer-queries.d.ts.map +1 -1
  201. package/lib/src/utils/graphql/answerService/answer-queries.js +7 -0
  202. package/lib/src/utils/graphql/answerService/answer-queries.js.map +1 -1
  203. package/lib/src/utils/graphql/answerService/answerService.d.ts +28 -4
  204. package/lib/src/utils/graphql/answerService/answerService.d.ts.map +1 -1
  205. package/lib/src/utils/graphql/answerService/answerService.js +39 -4
  206. package/lib/src/utils/graphql/answerService/answerService.js.map +1 -1
  207. package/lib/src/utils/graphql/answerService/answerService.spec.js +125 -1
  208. package/lib/src/utils/graphql/answerService/answerService.spec.js.map +1 -1
  209. package/lib/src/visual-embed-sdk.d.ts +744 -57
  210. package/package.json +1 -1
  211. package/src/css-variables.ts +475 -26
  212. package/src/embed/app.spec.ts +32 -0
  213. package/src/embed/app.ts +28 -4
  214. package/src/embed/auto-frame-renderer.spec.ts +464 -0
  215. package/src/embed/auto-frame-renderer.ts +305 -6
  216. package/src/embed/bodyless-conversation.ts +1 -1
  217. package/src/embed/conversation.spec.ts +94 -1
  218. package/src/embed/conversation.ts +28 -3
  219. package/src/embed/liveboard-override-utils.spec.ts +38 -0
  220. package/src/embed/liveboard-override-utils.ts +71 -0
  221. package/src/embed/liveboard.spec.ts +119 -92
  222. package/src/embed/liveboard.ts +56 -40
  223. package/src/embed/search-bar.tsx +1 -1
  224. package/src/embed/search.spec.ts +23 -0
  225. package/src/embed/search.ts +1 -1
  226. package/src/embed/spotter-utils.ts +12 -0
  227. package/src/embed/spotter-viz-utils.spec.ts +11 -0
  228. package/src/embed/spotter-viz-utils.ts +15 -0
  229. package/src/embed/ts-embed.spec.ts +685 -207
  230. package/src/embed/ts-embed.ts +227 -97
  231. package/src/index.ts +16 -1
  232. package/src/mixpanel-service.ts +1 -0
  233. package/src/react/all-types-export.ts +6 -0
  234. package/src/types.ts +186 -5
  235. package/src/utils/graphql/answerService/answer-queries.ts +8 -0
  236. package/src/utils/graphql/answerService/answerService.spec.ts +145 -1
  237. package/src/utils/graphql/answerService/answerService.ts +65 -3
@@ -8,6 +8,7 @@ import {
8
8
  ListPage,
9
9
  } from './app';
10
10
  import { SpotterQueryMode } from './conversation';
11
+ import { SpotterExperienceVersion } from './spotter-utils';
11
12
  import { init } from '../index';
12
13
  import { Action, AuthType, EmbedEvent, HostEvent, RuntimeFilterOp } from '../types';
13
14
  import {
@@ -3081,3 +3082,34 @@ describe('AppEmbed updatedSpotterExperience tests', () => {
3081
3082
  });
3082
3083
  });
3083
3084
  });
3085
+
3086
+ describe('AppEmbed spotterExperienceVersion tests', () => {
3087
+ beforeEach(() => {
3088
+ cleanUp();
3089
+ });
3090
+
3091
+ const renderWithConfig = async (overrides: Partial<AppViewConfig>) => {
3092
+ const appEmbed = new AppEmbed(getRootEl(), {
3093
+ ...defaultViewConfig,
3094
+ ...overrides,
3095
+ } as AppViewConfig);
3096
+ appEmbed.render();
3097
+ let src = '';
3098
+ await executeAfterWait(() => {
3099
+ src = getIFrameSrc();
3100
+ });
3101
+ return src;
3102
+ };
3103
+
3104
+ test('should add the spotterExperienceVersion param when spotterExperienceVersion is set', async () => {
3105
+ const src = await renderWithConfig({
3106
+ spotterExperienceVersion: SpotterExperienceVersion.SPOTTER_2026_11,
3107
+ });
3108
+ expect(src).toContain(`spotterExperienceVersion=${SpotterExperienceVersion.SPOTTER_2026_11}`);
3109
+ });
3110
+
3111
+ test('should not add the spotterExperienceVersion param when spotterExperienceVersion is not set', async () => {
3112
+ const src = await renderWithConfig({});
3113
+ expect(src).not.toContain('spotterExperienceVersion=');
3114
+ });
3115
+ });
package/src/embed/app.ts CHANGED
@@ -24,7 +24,12 @@ import {
24
24
  import { FullHeightController } from '../full-height';
25
25
  import { V1Embed } from './ts-embed';
26
26
  import { SpotterChatViewConfig, SpotterSidebarViewConfig, SpotterQueryMode, SpotterShareConversationConfig, StarterPromptsConfig } from './conversation';
27
- import { buildSpotterSidebarAppInitData, buildSpotterShareConversationAppInitData, buildStarterPromptsAppInitData } from './spotter-utils';
27
+ import {
28
+ buildSpotterSidebarAppInitData,
29
+ buildSpotterShareConversationAppInitData,
30
+ buildStarterPromptsAppInitData,
31
+ SpotterExperienceVersion,
32
+ } from './spotter-utils';
28
33
  import { SpotterVizConfig, buildSpotterVizAppInitData } from './spotter-viz-utils';
29
34
 
30
35
  /**
@@ -921,6 +926,20 @@ export interface AppViewConfig extends AllEmbedViewConfig, FullHeightViewConfig
921
926
  * ```
922
927
  */
923
928
  hideAnswerEditPanel?: boolean;
929
+
930
+ /**
931
+ * The Spotter experience version to load for the Spotter surface shown within
932
+ * this embed.
933
+ * @version SDK: 1.53.0 | ThoughtSpot Cloud: 26.11.0.cl
934
+ * @example
935
+ * ```js
936
+ * const embed = new AppEmbed('#tsEmbed', {
937
+ * ... // other options
938
+ * spotterExperienceVersion: SpotterExperienceVersion.SPOTTER_2026_11,
939
+ * });
940
+ * ```
941
+ */
942
+ spotterExperienceVersion?: SpotterExperienceVersion;
924
943
  }
925
944
 
926
945
  /**
@@ -991,7 +1010,7 @@ export class AppEmbed extends V1Embed {
991
1010
  * embedded Liveboard or visualization.
992
1011
  */
993
1012
  protected getEmbedParams() {
994
- const params = this.getEmbedParamsObject();
1013
+ const params = this.getUrlQueryParamsObject();
995
1014
  return getQueryParamString(params, true);
996
1015
  }
997
1016
 
@@ -1057,6 +1076,7 @@ export class AppEmbed extends V1Embed {
1057
1076
  isContinuousLiveboardPDFEnabled,
1058
1077
  enableLiveboardDataCache,
1059
1078
  hideAnswerEditPanel,
1079
+ spotterExperienceVersion,
1060
1080
  } = this.viewConfig;
1061
1081
 
1062
1082
  let params: any = {};
@@ -1117,6 +1137,10 @@ export class AppEmbed extends V1Embed {
1117
1137
  params[Param.SpotterDataSources] = JSON.stringify(spotterDataSources);
1118
1138
  }
1119
1139
 
1140
+ if (spotterExperienceVersion !== undefined) {
1141
+ params[Param.SpotterExperienceVersion] = spotterExperienceVersion;
1142
+ }
1143
+
1120
1144
  // Handle spotterChatConfig params
1121
1145
  if (spotterChatConfig) {
1122
1146
  const {
@@ -1281,7 +1305,7 @@ export class AppEmbed extends V1Embed {
1281
1305
 
1282
1306
  // Navigation V1/V2 and home page V1/V2 are deprecated. V3 is now the
1283
1307
  // default experience, Need to send navigationVersion=v3 and
1284
- // homepageVersion=v3 even when the no discoveryExperience /
1308
+ // homepageVersion=v3 even when the no discoveryExperience /
1285
1309
  // modularHomeExperience config is set. Without these
1286
1310
  // params, older TSA versions (< 26.7) fall back to V2 for nav and
1287
1311
  // home page, so the defaults must be sent explicitly from the SDK.
@@ -1293,7 +1317,7 @@ export class AppEmbed extends V1Embed {
1293
1317
  if (discoveryExperience.listPageVersion !== undefined) {
1294
1318
  params[Param.ListPageVersion] = discoveryExperience.listPageVersion;
1295
1319
  }
1296
-
1320
+
1297
1321
  if (discoveryExperience.homePage === HomePage.Focused) {
1298
1322
  params[Param.HomepageVersion] = HomePage.Focused;
1299
1323
  // The Focused (V4) homepage experience requires the updated
@@ -673,4 +673,468 @@ describe('startAutoMCPFrameRenderer', () => {
673
673
  expect(JSON.parse(autoValue)).toEqual(JSON.parse(liveboardValue));
674
674
  });
675
675
  });
676
+
677
+ // ─── replaying a stored conversation
678
+ // ──────────────────────────────────────
679
+
680
+ describe('stored conversation replay', () => {
681
+ const CONVERSATION_ID = 'U0Tfqmjlz6WT';
682
+ const REPLAY_SRC =
683
+ `https://${thoughtSpotHost}/v2/?${Param.Tsmcp}=true`
684
+ + `&${Param.TsmcpConversationId}=${CONVERSATION_ID}`
685
+ + `&${Param.TsmcpAnswerIndex}=1`;
686
+
687
+ /** Conversation-service shape: two real answers around a thinking one. */
688
+ const messagesPayload = {
689
+ messages: [
690
+ {
691
+ message_id: 'm1',
692
+ response_items: [
693
+ { type: 'tool_call', is_thinking: false },
694
+ { type: 'answer', answer_id: 'ans_first', is_thinking: false },
695
+ ],
696
+ },
697
+ {
698
+ message_id: 'm2',
699
+ response_items: [
700
+ { type: 'answer', answer_id: 'ans_thinking', is_thinking: true },
701
+ { type: 'answer', answer_id: 'ans_second', is_thinking: false },
702
+ ],
703
+ },
704
+ ],
705
+ };
706
+
707
+ const detailsPayload = {
708
+ answer: {
709
+ session_identifier: 'fresh-session',
710
+ generation_number: 3,
711
+ ac_state: { transaction_identifier: 'fresh-ac', generation_number: 2 },
712
+ },
713
+ };
714
+
715
+ let fetchMock: jest.SpyInstance;
716
+
717
+ /** Renders one replay iframe and returns the src plus the frame element. */
718
+ async function renderReplayFrame(
719
+ viewConfig: AutoMCPFrameRendererViewConfig = {},
720
+ src: string = REPLAY_SRC,
721
+ ): Promise<{ renderedSrc: string; frame: HTMLIFrameElement }> {
722
+ let renderedSrc = '';
723
+ let frame: HTMLIFrameElement;
724
+ renderIFrameSpy.mockRestore();
725
+ renderIFrameSpy = jest.spyOn(TsEmbed.prototype as any, 'renderIFrame')
726
+ .mockImplementation(async function (this: any, capturedSrc: string) {
727
+ renderedSrc = capturedSrc;
728
+ // Stand in for the real iframe the notice attaches to.
729
+ frame = document.createElement('iframe');
730
+ this.iFrame = frame;
731
+ });
732
+
733
+ const observer = startAutoMCPFrameRenderer(viewConfig);
734
+ const iframe = document.createElement('iframe');
735
+ iframe.src = src;
736
+ document.body.appendChild(iframe);
737
+ await new Promise((r) => setTimeout(r, 100));
738
+ observer.disconnect();
739
+ return { renderedSrc, frame: frame! };
740
+ }
741
+
742
+ beforeEach(() => {
743
+ fetchMock = jest.spyOn(global, 'fetch').mockImplementation(async (input: any) => {
744
+ const url = typeof input === 'string' ? input : input.url;
745
+ const body = url.includes('/load/public') ? detailsPayload : messagesPayload;
746
+ return { ok: true, status: 200, json: async () => body } as any;
747
+ });
748
+ });
749
+
750
+ afterEach(() => {
751
+ fetchMock.mockRestore();
752
+ });
753
+
754
+ test('resolves session params from the conversation and answer APIs', async () => {
755
+ const { renderedSrc } = await renderReplayFrame();
756
+
757
+ expect(renderedSrc).toContain('sessionId=fresh-session');
758
+ expect(renderedSrc).toContain('genNo=3');
759
+ expect(renderedSrc).toContain('acSessionId=fresh-ac');
760
+ expect(renderedSrc).toContain('acGenNo=2');
761
+ });
762
+
763
+ test('picks the answer at the requested index, skipping thinking items', async () => {
764
+ await renderReplayFrame();
765
+
766
+ const detailsCall = fetchMock.mock.calls
767
+ .map(([input]: any) => (typeof input === 'string' ? input : input.url))
768
+ .find((url: string) => url.includes('/load/public'));
769
+ // index 1 = second non-thinking answer; ans_thinking must not
770
+ // count.
771
+ expect(detailsCall).toContain('/message/ans_second/load/public');
772
+ });
773
+
774
+ test('defaults to the conversational answer route when none is given', async () => {
775
+ const { renderedSrc } = await renderReplayFrame();
776
+ expect(renderedSrc).toContain('/embed/conv-assist-answer');
777
+ });
778
+
779
+ test('strips the replay markers from the URL sent to ThoughtSpot', async () => {
780
+ const { renderedSrc } = await renderReplayFrame();
781
+ expect(renderedSrc).not.toContain(Param.TsmcpConversationId);
782
+ expect(renderedSrc).not.toContain(Param.TsmcpAnswerIndex);
783
+ expect(renderedSrc).not.toContain(`${Param.Tsmcp}=true`);
784
+ });
785
+
786
+ test('marks a replayed answer with the stale-data notice', async () => {
787
+ const { frame } = await renderReplayFrame();
788
+ expect(frame.dataset.tsStaleAnswer).toBe('true');
789
+ expect(frame.title).toBe(
790
+ 'This data may have changed since the last time you had a chat.',
791
+ );
792
+ });
793
+
794
+ test('suppressStaleAnswerNotice leaves the frame unmarked', async () => {
795
+ const { frame } = await renderReplayFrame({ suppressStaleAnswerNotice: true });
796
+ expect(frame.dataset.tsStaleAnswer).toBeUndefined();
797
+ expect(frame.title).toBe('');
798
+ });
799
+
800
+ test('staleAnswerNoticeText overrides the default copy', async () => {
801
+ const { frame } = await renderReplayFrame({ staleAnswerNoticeText: 'Numbers may differ.' });
802
+ expect(frame.title).toBe('Numbers may differ.');
803
+ });
804
+
805
+ test('falls back to the source URL when resolution fails', async () => {
806
+ fetchMock.mockImplementation(async () => ({ ok: false, status: 500 } as any));
807
+ const { renderedSrc, frame } = await renderReplayFrame();
808
+
809
+ // No invented params, and no notice claiming a successful refresh.
810
+ expect(renderedSrc).toBe(REPLAY_SRC);
811
+ expect(renderedSrc).not.toContain('sessionId=');
812
+ expect(frame.dataset.tsStaleAnswer).toBeUndefined();
813
+ });
814
+
815
+ test.each([
816
+ ['a null messages body', null],
817
+ ['a messages body that is not an object', 'nope'],
818
+ ['messages that is not an array', { messages: null }],
819
+ ])('falls back on %s instead of throwing', async (_label, body) => {
820
+ fetchMock.mockImplementation(async (input: any) => {
821
+ const url = typeof input === 'string' ? input : input.url;
822
+ return {
823
+ ok: true,
824
+ status: 200,
825
+ json: async () => (url.includes('/load/public') ? detailsPayload : body),
826
+ } as any;
827
+ });
828
+ const { renderedSrc, frame } = await renderReplayFrame();
829
+
830
+ expect(renderedSrc).not.toContain('sessionId=');
831
+ expect(frame.dataset.tsStaleAnswer).toBeUndefined();
832
+ });
833
+
834
+ test('falls back on a null details body instead of throwing', async () => {
835
+ fetchMock.mockImplementation(async (input: any) => {
836
+ const url = typeof input === 'string' ? input : input.url;
837
+ return {
838
+ ok: true,
839
+ status: 200,
840
+ json: async () => (url.includes('/load/public') ? null : messagesPayload),
841
+ } as any;
842
+ });
843
+ const { renderedSrc } = await renderReplayFrame();
844
+
845
+ expect(renderedSrc).not.toContain('sessionId=');
846
+ });
847
+
848
+ test.each([
849
+ ['answer.generation_number', { ...detailsPayload.answer, generation_number: null }],
850
+ [
851
+ 'ac_state.generation_number',
852
+ { ...detailsPayload.answer, ac_state: { transaction_identifier: 'fresh-ac', generation_number: null } },
853
+ ],
854
+ ])('treats a null %s as unresolvable', async (_label, answer) => {
855
+ fetchMock.mockImplementation(async (input: any) => {
856
+ const url = typeof input === 'string' ? input : input.url;
857
+ return {
858
+ ok: true,
859
+ status: 200,
860
+ json: async () => (url.includes('/load/public') ? { answer } : messagesPayload),
861
+ } as any;
862
+ });
863
+ const { renderedSrc, frame } = await renderReplayFrame();
864
+
865
+ // Never emit a literal "null" generation into the hash.
866
+ expect(renderedSrc).not.toContain('genNo=null');
867
+ expect(renderedSrc).not.toContain('acGenNo=null');
868
+ expect(renderedSrc).not.toContain('sessionId=');
869
+ expect(frame.dataset.tsStaleAnswer).toBeUndefined();
870
+ });
871
+
872
+ test.each([
873
+ ['a fractional index', '1.5'],
874
+ ['a negative index', '-1'],
875
+ ['a malformed index', 'abc'],
876
+ ['a trailing-junk index', '1abc'],
877
+ ])('%s falls back to the first answer', async (_label, value) => {
878
+ const src = `https://${thoughtSpotHost}/v2/?${Param.Tsmcp}=true`
879
+ + `&${Param.TsmcpConversationId}=${CONVERSATION_ID}`
880
+ + `&${Param.TsmcpAnswerIndex}=${value}`;
881
+ await renderReplayFrame({}, src);
882
+
883
+ const detailsCall = fetchMock.mock.calls
884
+ .map(([input]: any) => (typeof input === 'string' ? input : input.url))
885
+ .find((url: string) => url.includes('/load/public'));
886
+ expect(detailsCall).toContain('/message/ans_first/load/public');
887
+ });
888
+
889
+ /** URLs passed to fetch, in call order. */
890
+ function fetchedUrls(): string[] {
891
+ return fetchMock.mock.calls
892
+ .map(([input]: any) => (typeof input === 'string' ? input : input.url));
893
+ }
894
+
895
+ /** Serves `messages` for the list call and `details` for the load call. */
896
+ function mockConversation(messages: any, details: any = detailsPayload) {
897
+ fetchMock.mockImplementation(async (input: any) => {
898
+ const url = typeof input === 'string' ? input : input.url;
899
+ return {
900
+ ok: true,
901
+ status: 200,
902
+ json: async () => (url.includes('/load/public') ? details : messages),
903
+ } as any;
904
+ });
905
+ }
906
+
907
+ test('calls the conversation service with the encoded conversation id', async () => {
908
+ const src = `https://${thoughtSpotHost}/v2/?${Param.Tsmcp}=true`
909
+ + `&${Param.TsmcpConversationId}=${encodeURIComponent('conv/1')}`;
910
+ await renderReplayFrame({}, src);
911
+
912
+ const [listUrl, loadUrl] = fetchedUrls();
913
+ expect(listUrl).toBe(`http://${thoughtSpotHost}/conversation/v2/conv%2F1/public`);
914
+ expect(loadUrl).toBe(
915
+ `http://${thoughtSpotHost}/conversation/v2/conv%2F1/message/ans_first/load/public`,
916
+ );
917
+ const loadInit = fetchMock.mock.calls[1][1];
918
+ expect(loadInit.method).toBe('POST');
919
+ expect(JSON.parse(loadInit.body)).toEqual({ type: 'TS_ANSWER' });
920
+ });
921
+
922
+ test('defaults to the first answer when no index is given', async () => {
923
+ const src = `https://${thoughtSpotHost}/v2/?${Param.Tsmcp}=true`
924
+ + `&${Param.TsmcpConversationId}=${CONVERSATION_ID}`;
925
+ await renderReplayFrame({}, src);
926
+
927
+ expect(fetchedUrls()[1]).toContain('/message/ans_first/load/public');
928
+ });
929
+
930
+ test('keeps the source route and its other hash params', async () => {
931
+ const src = `${REPLAY_SRC}#/embed/viz/lb1?keep=yes&sessionId=expired`;
932
+ const { renderedSrc } = await renderReplayFrame({}, src);
933
+
934
+ expect(renderedSrc).toContain('/embed/viz/lb1?');
935
+ expect(renderedSrc).not.toContain('/embed/conv-assist-answer');
936
+ expect(renderedSrc).toContain('keep=yes');
937
+ expect(renderedSrc).toContain('sessionId=fresh-session');
938
+ expect(renderedSrc).not.toContain('sessionId=expired');
939
+ });
940
+
941
+ test('ignores messages without response items and answers without an id', async () => {
942
+ mockConversation({
943
+ messages: [
944
+ { message_id: 'm0' },
945
+ null,
946
+ { message_id: 'm1', response_items: [{ type: 'answer', is_thinking: false }] },
947
+ {
948
+ message_id: 'm2',
949
+ response_items: [null, { type: 'answer', answer_id: 'ans_only', is_thinking: false }],
950
+ },
951
+ ],
952
+ });
953
+ const src = `https://${thoughtSpotHost}/v2/?${Param.Tsmcp}=true`
954
+ + `&${Param.TsmcpConversationId}=${CONVERSATION_ID}`;
955
+ const { renderedSrc } = await renderReplayFrame({}, src);
956
+
957
+ expect(fetchedUrls()[1]).toContain('/message/ans_only/load/public');
958
+ expect(renderedSrc).toContain('sessionId=fresh-session');
959
+ });
960
+
961
+ test('falls back when no answer exists at the requested index', async () => {
962
+ const src = `https://${thoughtSpotHost}/v2/?${Param.Tsmcp}=true`
963
+ + `&${Param.TsmcpConversationId}=${CONVERSATION_ID}`
964
+ + `&${Param.TsmcpAnswerIndex}=5`;
965
+ const { renderedSrc, frame } = await renderReplayFrame({}, src);
966
+
967
+ // The list, read once more in case it predated the answer; there is
968
+ // no answer to load.
969
+ expect(fetchedUrls()).toHaveLength(2);
970
+ expect(fetchedUrls().every((url) => url.endsWith('/public') && !url.includes('/load/'))).toBe(true);
971
+ expect(renderedSrc).not.toContain('sessionId=');
972
+ expect(frame.dataset.tsStaleAnswer).toBeUndefined();
973
+ });
974
+
975
+ test('falls back when the answer load fails', async () => {
976
+ fetchMock.mockImplementation(async (input: any) => {
977
+ const url = typeof input === 'string' ? input : input.url;
978
+ if (url.includes('/load/public')) {
979
+ return { ok: false, status: 404 } as any;
980
+ }
981
+ return { ok: true, status: 200, json: async () => messagesPayload } as any;
982
+ });
983
+ const { renderedSrc, frame } = await renderReplayFrame();
984
+
985
+ expect(fetchedUrls()).toHaveLength(2);
986
+ expect(renderedSrc).not.toContain('sessionId=');
987
+ expect(frame.dataset.tsStaleAnswer).toBeUndefined();
988
+ });
989
+
990
+ test('falls back when a fetch throws', async () => {
991
+ fetchMock.mockImplementation(async () => {
992
+ throw new Error('network down');
993
+ });
994
+ const { renderedSrc, frame } = await renderReplayFrame();
995
+
996
+ expect(renderedSrc).not.toContain('sessionId=');
997
+ expect(frame.dataset.tsStaleAnswer).toBeUndefined();
998
+ });
999
+
1000
+ test.each([
1001
+ ['session_identifier', { ...detailsPayload.answer, session_identifier: '' }],
1002
+ [
1003
+ 'ac_state.transaction_identifier',
1004
+ { ...detailsPayload.answer, ac_state: { generation_number: 2 } },
1005
+ ],
1006
+ ['ac_state', { ...detailsPayload.answer, ac_state: undefined }],
1007
+ ['a positive generation_number', { ...detailsPayload.answer, generation_number: 0 }],
1008
+ ])('treats a missing %s as unresolvable', async (_label, answer) => {
1009
+ mockConversation(messagesPayload, { answer });
1010
+ const { renderedSrc, frame } = await renderReplayFrame();
1011
+
1012
+ expect(renderedSrc).not.toContain('sessionId=');
1013
+ expect(frame.dataset.tsStaleAnswer).toBeUndefined();
1014
+ });
1015
+
1016
+ test('skips the notice when no iframe was rendered', async () => {
1017
+ renderIFrameSpy.mockRestore();
1018
+ renderIFrameSpy = jest.spyOn(TsEmbed.prototype as any, 'renderIFrame')
1019
+ .mockImplementation(async function (this: any) {
1020
+ this.iFrame = undefined;
1021
+ });
1022
+
1023
+ const observer = startAutoMCPFrameRenderer({});
1024
+ const iframe = document.createElement('iframe');
1025
+ iframe.src = REPLAY_SRC;
1026
+ document.body.appendChild(iframe);
1027
+ await new Promise((r) => setTimeout(r, 100));
1028
+ observer.disconnect();
1029
+
1030
+ // Resolution ran, and marking a missing frame did not throw.
1031
+ expect(fetchedUrls()).toHaveLength(2);
1032
+ expect(renderIFrameSpy).toHaveBeenCalledTimes(1);
1033
+ });
1034
+
1035
+ describe('sharing the answer-id list', () => {
1036
+ const replaySrc = (index: number, conversationId = CONVERSATION_ID) =>
1037
+ `https://${thoughtSpotHost}/v2/?${Param.Tsmcp}=true`
1038
+ + `&${Param.TsmcpConversationId}=${conversationId}`
1039
+ + `&${Param.TsmcpAnswerIndex}=${index}`;
1040
+
1041
+ /**
1042
+ * Mounts all `srcs` in one batch, as a replayed chat does.
1043
+ * Returns the rendered srcs.
1044
+ */
1045
+ async function renderReplayBatch(srcs: string[]): Promise<string[]> {
1046
+ const rendered: string[] = [];
1047
+ renderIFrameSpy.mockRestore();
1048
+ renderIFrameSpy = jest.spyOn(TsEmbed.prototype as any, 'renderIFrame')
1049
+ .mockImplementation(async function (this: any, capturedSrc: string) {
1050
+ rendered.push(capturedSrc);
1051
+ this.iFrame = document.createElement('iframe');
1052
+ });
1053
+
1054
+ const observer = startAutoMCPFrameRenderer({});
1055
+ const container = document.createElement('div');
1056
+ srcs.forEach((src) => {
1057
+ const iframe = document.createElement('iframe');
1058
+ iframe.src = src;
1059
+ container.appendChild(iframe);
1060
+ });
1061
+ document.body.appendChild(container);
1062
+ await new Promise((r) => setTimeout(r, 100));
1063
+ observer.disconnect();
1064
+ return rendered;
1065
+ }
1066
+
1067
+ const listCalls = () => fetchedUrls().filter((url) => !url.includes('/load/'));
1068
+ const loadCalls = () => fetchedUrls().filter((url) => url.includes('/load/'));
1069
+
1070
+ test('concurrent frames from one conversation share one list call', async () => {
1071
+ const rendered = await renderReplayBatch([replaySrc(0), replaySrc(1)]);
1072
+
1073
+ expect(listCalls()).toHaveLength(1);
1074
+ // Each answer still loads its own live session.
1075
+ expect(loadCalls()).toHaveLength(2);
1076
+ expect(loadCalls().some((url) => url.includes('/message/ans_first/'))).toBe(true);
1077
+ expect(loadCalls().some((url) => url.includes('/message/ans_second/'))).toBe(true);
1078
+ expect(rendered.every((src) => src.includes('sessionId=fresh-session'))).toBe(true);
1079
+ });
1080
+
1081
+ test('frames from different conversations do not share a list', async () => {
1082
+ await renderReplayBatch([replaySrc(0), replaySrc(0, 'otherConversation')]);
1083
+
1084
+ expect(listCalls()).toHaveLength(2);
1085
+ expect(listCalls().some((url) => url.includes('/otherConversation/'))).toBe(true);
1086
+ });
1087
+
1088
+ test('a failed list is not reused by a later frame', async () => {
1089
+ fetchMock.mockImplementation(async () => ({ ok: false, status: 500 } as any));
1090
+ await renderReplayBatch([replaySrc(0)]);
1091
+ expect(listCalls()).toHaveLength(1);
1092
+
1093
+ mockConversation(messagesPayload);
1094
+ const rendered = await renderReplayBatch([replaySrc(0)]);
1095
+
1096
+ expect(listCalls()).toHaveLength(2);
1097
+ expect(rendered[0]).toContain('sessionId=fresh-session');
1098
+ });
1099
+
1100
+ test('a frame mounted after the batch settles reads a fresh list', async () => {
1101
+ await renderReplayBatch([replaySrc(0)]);
1102
+ await renderReplayBatch([replaySrc(1)]);
1103
+
1104
+ expect(listCalls()).toHaveLength(2);
1105
+ });
1106
+
1107
+ test('a list too short for the index is read exactly once more', async () => {
1108
+ // The first read predates the second answer; the
1109
+ // second read has it.
1110
+ let listReads = 0;
1111
+ fetchMock.mockImplementation(async (input: any) => {
1112
+ const url = typeof input === 'string' ? input : input.url;
1113
+ if (url.includes('/load/public')) {
1114
+ return { ok: true, status: 200, json: async () => detailsPayload } as any;
1115
+ }
1116
+ listReads += 1;
1117
+ const messages = listReads === 1
1118
+ ? { messages: [messagesPayload.messages[0]] }
1119
+ : messagesPayload;
1120
+ return { ok: true, status: 200, json: async () => messages } as any;
1121
+ });
1122
+ const rendered = await renderReplayBatch([replaySrc(1), replaySrc(1)]);
1123
+
1124
+ // Both frames missed on the first list and joined one re-read.
1125
+ expect(listCalls()).toHaveLength(2);
1126
+ expect(loadCalls()).toHaveLength(2);
1127
+ expect(loadCalls().every((url) => url.includes('/message/ans_second/'))).toBe(true);
1128
+ expect(rendered.every((src) => src.includes('sessionId=fresh-session'))).toBe(true);
1129
+ });
1130
+ });
1131
+
1132
+ test('a frame without a conversation id makes no API calls and is unmarked', async () => {
1133
+ const { renderedSrc, frame } = await renderReplayFrame({}, TSMCP_SRC);
1134
+
1135
+ expect(fetchMock).not.toHaveBeenCalled();
1136
+ expect(renderedSrc).toContain('/embed/viz/lb1');
1137
+ expect(frame.dataset.tsStaleAnswer).toBeUndefined();
1138
+ });
1139
+ });
676
1140
  });