From 34c07ac51462d3393e36c23cf95c30544eca8db1 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Fri, 10 Oct 2025 12:59:39 +0530 Subject: [PATCH 01/28] SCAL-269016 : rebase --- src/api-intercept.ts | 131 +++++++++++++++++++++++++ src/embed/app.ts | 5 +- src/embed/hostEventClient/contracts.ts | 10 ++ src/embed/search.ts | 4 +- src/embed/ts-embed.spec.ts | 14 +-- src/embed/ts-embed.ts | 98 ++++++++++++------ src/index.ts | 2 + src/react/all-types-export.ts | 1 + src/types.ts | 83 +++++++++++++++- src/utils/processData.ts | 22 ++--- 10 files changed, 317 insertions(+), 53 deletions(-) create mode 100644 src/api-intercept.ts diff --git a/src/api-intercept.ts b/src/api-intercept.ts new file mode 100644 index 00000000..3b952449 --- /dev/null +++ b/src/api-intercept.ts @@ -0,0 +1,131 @@ +import { getThoughtSpotHost } from "./config"; +import { getEmbedConfig } from "./embed/embedConfig"; +import { InterceptedApiType, BaseViewConfig, EmbedConfig, InterceptV2Flags, EmbedEvent } from "./types"; + + + +const defaultUrls: Record, string[]> = { + [InterceptedApiType.METADATA]: [ + '/prism/?op=CreateAnswerSession', + '/prism/?op=GetV2SourceDetail', + ] as string[], + [InterceptedApiType.DATA]: [ + '/prism/?op=GetChartWithData', + '/prism/?op=GetTableWithHeadlineData', + '/prism/?op=LoadContextBook' + ] as string[], +}; + +const formatInterceptUrl = (url: string) => { + const host = getThoughtSpotHost(getEmbedConfig()); + if (url.startsWith('/')) return `${host}${url}`; + return url; +} + +export const processApiIntercept = async (eventData: any) => { + + return JSON.parse(eventData.data); +} + +interface LegacyInterceptFlags { + isOnBeforeGetVizDataInterceptEnabled: boolean; +} + +const processInterceptUrls = (combinedUrls: (string | InterceptedApiType)[]) => { + Object.entries(defaultUrls).forEach(([apiType, apiTypeUrls]) => { + if (!combinedUrls.includes(apiType)) return; + combinedUrls = combinedUrls.filter(url => url !== apiType); + combinedUrls = [...combinedUrls, ...apiTypeUrls]; + }) + return combinedUrls.map(url => formatInterceptUrl(url)); +} +export const getInterceptInitData = (embedConfig: EmbedConfig, viewConfig: BaseViewConfig): InterceptV2Flags => { + + const enableApiIntercept = (embedConfig.enableApiIntercept || viewConfig.enableApiIntercept) && (viewConfig.enableApiIntercept !== false); + + if (!enableApiIntercept) return { + enableApiIntercept: false, + }; + + const combinedUrls = [...(embedConfig.interceptUrls || []), ...(viewConfig.interceptUrls || [])]; + + if ((viewConfig as LegacyInterceptFlags).isOnBeforeGetVizDataInterceptEnabled) { + combinedUrls.push(InterceptedApiType.DATA); + } + + const shouldInterceptAll = combinedUrls.includes(InterceptedApiType.ALL); + const interceptUrls = shouldInterceptAll ? [InterceptedApiType.ALL] : processInterceptUrls(combinedUrls); + + const interceptTimeout = embedConfig.interceptTimeout || viewConfig.interceptTimeout; + + return { + interceptUrls, + interceptTimeout, + enableApiIntercept, + }; +} + +/** + * + * @param fetchInit + */ +const parseInterceptData = (eventDataString: any) => { + + try { + const { input, init } = JSON.parse(eventDataString); + + init.body = JSON.parse(init.body); + + const parsedInit = { input, init }; + return [parsedInit, null]; + } catch (error) { + return [null, error]; + } +} + +export const handleInterceptEvent = async (params: { eventData: any, executeEvent: (eventType: EmbedEvent, data: any) => void, embedConfig: EmbedConfig, viewConfig: BaseViewConfig, getUnsavedAnswerTml: (props: { sessionId?: string, vizId?: string }) => Promise<{ tml: string }> }) => { + + const { eventData, executeEvent, viewConfig, getUnsavedAnswerTml } = params; + + const [interceptData, bodyParseError] = parseInterceptData(eventData.data); + + if (bodyParseError) { + executeEvent(EmbedEvent.Error, { + error: 'Error parsing api intercept body', + }); + logger.error('Error parsing request body', bodyParseError); + return; + } + + const { input: requestUrl, init } = interceptData; + + const sessionId = init?.body?.variables?.session?.sessionId; + const vizId = init?.body?.variables?.contextBookId; + + if (defaultUrls.DATA.includes(requestUrl) && (viewConfig as LegacyInterceptFlags).isOnBeforeGetVizDataInterceptEnabled) { + const answerTml = await getUnsavedAnswerTml({ sessionId, vizId }); + executeEvent(EmbedEvent.OnBeforeGetVizDataIntercept, { data: { data: answerTml } }); + } + + executeEvent(EmbedEvent.ApiIntercept, interceptData); +} + +export const processLegacyInterceptResponse = (payload: any) => { + + const title = payload?.data?.errorText; + const desc = payload?.data?.errorDescription; + + const payloadToSend = [{ + data: {}, + errors: [ + { + errorObj: { + title, + desc + } + } + ], + }]; + + return payloadToSend; +} diff --git a/src/embed/app.ts b/src/embed/app.ts index 6adecdd1..fedb6410 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -19,6 +19,7 @@ import { AllEmbedViewConfig, } from '../types'; import { V1Embed } from './ts-embed'; +import { getInterceptInitData } from '../api-intercept'; /** * Pages within the ThoughtSpot app that can be embedded. @@ -752,7 +753,9 @@ export class AppEmbed extends V1Embed { params[Param.enableAskSage] = enableAskSage; } - if (isOnBeforeGetVizDataInterceptEnabled) { + const { enableApiIntercept } = getInterceptInitData(this.embedConfig, this.viewConfig); + + if (isOnBeforeGetVizDataInterceptEnabled && !enableApiIntercept) { params[ Param.IsOnBeforeGetVizDataInterceptEnabled diff --git a/src/embed/hostEventClient/contracts.ts b/src/embed/hostEventClient/contracts.ts index f82077b7..0f2e07f1 100644 --- a/src/embed/hostEventClient/contracts.ts +++ b/src/embed/hostEventClient/contracts.ts @@ -7,6 +7,7 @@ export enum UIPassthroughEvent { GetAvailableUIPassthroughs = 'getAvailableUiPassthroughs', GetAnswerConfig = 'getAnswerPageConfig', GetLiveboardConfig = 'getPinboardPageConfig', + GetUnsavedAnswerTML = 'getUnsavedAnswerTML', } // UI Passthrough Contract @@ -63,6 +64,15 @@ export type UIPassthroughContractBase = { request: any; response: any; }; + [UIPassthroughEvent.GetUnsavedAnswerTML]: { + request: { + sessionId?: string; + vizId?: string; + }; + response: { + tml: string; + }; + }; }; // UI Passthrough Request and Response diff --git a/src/embed/search.ts b/src/embed/search.ts index 534db212..186271d2 100644 --- a/src/embed/search.ts +++ b/src/embed/search.ts @@ -26,6 +26,7 @@ import { ERROR_MESSAGE } from '../errors'; import { getAuthPromise } from './base'; import { getReleaseVersion } from '../auth'; import { getEmbedConfig } from './embedConfig'; +import { getInterceptInitData } from '../api-intercept'; /** * Configuration for search options. @@ -442,7 +443,8 @@ export class SearchEmbed extends TsEmbed { queryParams[Param.HideSearchBar] = true; } - if (isOnBeforeGetVizDataInterceptEnabled) { + const { enableApiIntercept } = getInterceptInitData(this.embedConfig, this.viewConfig); + if (isOnBeforeGetVizDataInterceptEnabled && !enableApiIntercept) { queryParams[Param.IsOnBeforeGetVizDataInterceptEnabled] = isOnBeforeGetVizDataInterceptEnabled; } diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 9e1ef649..662c0e70 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -1045,7 +1045,7 @@ describe('Unit test case for ts embed', () => { type: EmbedEvent.APP_INIT, data: {}, }; - + // Create a SearchEmbed with valid custom actions to test // CustomActionsValidationResult const searchEmbed = new SearchEmbed(getRootEl(), { @@ -1067,7 +1067,7 @@ describe('Unit test case for ts embed', () => { } ] }); - + searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), @@ -1116,7 +1116,7 @@ describe('Unit test case for ts embed', () => { customVariablesForThirdPartyTools: {}, }, }); - + // Verify that CustomActionsValidationResult structure is // correct const appInitData = mockPort.postMessage.mock.calls[0][0].data; @@ -1137,7 +1137,7 @@ describe('Unit test case for ts embed', () => { }) ]) ); - + // Verify actions are sorted by name (alphabetically) expect(appInitData.customActions[0].name).toBe('Another Valid Action'); expect(appInitData.customActions[1].name).toBe('Valid Action'); @@ -2444,7 +2444,7 @@ describe('Unit test case for ts embed', () => { }); afterAll((): void => { - window.location = location as any; + (window.location as any) = location; }); it('get url params for TS', () => { @@ -3362,7 +3362,7 @@ describe('Unit test case for ts embed', () => { new Error('Auth failed'), ); const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); await searchEmbed.render(); await executeAfterWait(() => { expect(getRootEl().innerHTML).toContain('Not logged in'); @@ -3371,7 +3371,7 @@ describe('Unit test case for ts embed', () => { window.dispatchEvent(onlineEvent); }).not.toThrow(); }); - + errorSpy.mockReset(); }); diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index d7c185ef..a4545826 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -71,6 +71,7 @@ import { getEmbedConfig } from './embedConfig'; import { ERROR_MESSAGE } from '../errors'; import { getPreauthInfo } from '../utils/sessionInfoService'; import { HostEventClient } from './hostEventClient/host-event-client'; +import { getInterceptInitData, handleInterceptEvent, processLegacyInterceptResponse } from '../api-intercept'; const { version } = pkgInfo; @@ -201,7 +202,7 @@ export class TsEmbed { }); const embedConfig = getEmbedConfig(); this.embedConfig = embedConfig; - + this.hostEventClient = new HostEventClient(this.iFrame); this.isReadyForRenderPromise = getInitPromise().then(async () => { if (!embedConfig.authTriggerContainer && !embedConfig.useEventForSAMLPopup) { @@ -336,33 +337,50 @@ export class TsEmbed { this.subscribedListeners.offline = offlineEventListener; } + private messageEventListener = async (event: MessageEvent) => { + const eventType = this.getEventType(event); + const eventPort = this.getEventPort(event); + const eventData = this.formatEventData(event, eventType); + if (event.source === this.iFrame.contentWindow) { + const processedEventData = await processEventData( + eventType, + eventData, + this.thoughtSpotHost, + this.isPreRendered ? this.preRenderWrapper : this.el, + ); + + const executeEvent = (_eventType: EmbedEvent, data: any) => { + this.executeCallbacks(_eventType, data, eventPort); + } + + if (eventType === EmbedEvent.ApiIntercept && this.viewConfig.enableApiIntercept) { + const getUnsavedAnswerTml = async (props: { sessionId?: string, vizId?: string }) => { + const response = await this.triggerUIPassThrough(UIPassthroughEvent.GetUnsavedAnswerTML, props); + return response[0]?.value; + } + handleInterceptEvent({ eventData: processedEventData, executeEvent, embedConfig: this.embedConfig, viewConfig: this.viewConfig, getUnsavedAnswerTml }); + return; + } + + this.executeCallbacks( + eventType, + processedEventData, + eventPort, + ); + } + }; /** * Subscribe to message events that depend on successful iframe setup */ private subscribeToMessageEvents() { this.unsubscribeToMessageEvents(); - const messageEventListener = (event: MessageEvent) => { - const eventType = this.getEventType(event); - const eventPort = this.getEventPort(event); - const eventData = this.formatEventData(event, eventType); - if (event.source === this.iFrame.contentWindow) { - this.executeCallbacks( - eventType, - processEventData( - eventType, - eventData, - this.thoughtSpotHost, - this.isPreRendered ? this.preRenderWrapper : this.el, - ), - eventPort, - ); - } - }; - window.addEventListener('message', messageEventListener); + window.addEventListener('message', this.messageEventListener); - this.subscribedListeners.message = messageEventListener; + this.subscribedListeners.message = this.messageEventListener; } + + /** * Adds event listeners for both network and message events. * This maintains backward compatibility with the existing method. @@ -376,6 +394,7 @@ export class TsEmbed { this.subscribeToMessageEvents(); } + private unsubscribeToNetworkEvents() { if (this.subscribedListeners.online) { window.removeEventListener('online', this.subscribedListeners.online); @@ -426,7 +445,7 @@ export class TsEmbed { message: customActionsResult.errors, }); } - return { + const baseInitData = { customisations: getCustomisations(this.embedConfig, this.viewConfig), authToken, runtimeFilterParams: this.viewConfig.excludeRuntimeFiltersfromURL @@ -445,7 +464,10 @@ export class TsEmbed { this.embedConfig.customVariablesForThirdPartyTools || {}, hiddenListColumns: this.viewConfig.hiddenListColumns || [], customActions: customActionsResult.actions, + ...getInterceptInitData(this.embedConfig, this.viewConfig), }; + + return baseInitData; } protected async getAppInitData() { @@ -1023,6 +1045,21 @@ export class TsEmbed { this.iFrame.style.height = getCssDimension(height); } + protected createEmbedEventResponder = (eventPort: MessagePort | void, eventType: EmbedEvent) => { + + const { enableApiIntercept } = getInterceptInitData(this.embedConfig, this.viewConfig); + if (eventType === EmbedEvent.OnBeforeGetVizDataIntercept && enableApiIntercept) { + return (payload: any) => { + const payloadToSend = processLegacyInterceptResponse(payload); + this.triggerEventOnPort(eventPort, payloadToSend); + } + } + + return (payload: any) => { + this.triggerEventOnPort(eventPort, payload); + } + } + /** * Executes all registered event handlers for a particular event type * @param eventType The event type @@ -1047,9 +1084,8 @@ export class TsEmbed { // payload || (!callbackObj.options.start && dataStatus === embedEventStatus.END) ) { - callbackObj.callback(data, (payload) => { - this.triggerEventOnPort(eventPort, payload); - }); + const responder = this.createEmbedEventResponder(eventPort, eventType); + callbackObj.callback(data, responder); } }); } @@ -1191,12 +1227,12 @@ export class TsEmbed { } } - /** - * @hidden - * Internal state to track if the embed container is loaded. - * This is used to trigger events after the embed container is loaded. - */ - public isEmbedContainerLoaded = false; + /** + * @hidden + * Internal state to track if the embed container is loaded. + * This is used to trigger events after the embed container is loaded. + */ + public isEmbedContainerLoaded = false; /** * @hidden @@ -1348,7 +1384,7 @@ export class TsEmbed { } this.isPreRendered = true; this.showPreRenderByDefault = showPreRenderByDefault; - + const isAlreadyRendered = this.connectPreRendered(); if (isAlreadyRendered && !replaceExistingPreRender) { return this; diff --git a/src/index.ts b/src/index.ts index 9b4c18fd..9d38a3a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,7 @@ import { ListPageColumns, CustomActionsPosition, CustomActionTarget, + InterceptedApiType, } from './types'; import { CustomCssVariables } from './css-variables'; import { SageEmbed, SageViewConfig } from './embed/sage'; @@ -152,6 +153,7 @@ export { DataPanelCustomColumnGroupsAccordionState, CustomActionsPosition, CustomActionTarget, + InterceptedApiType, }; export { resetCachedAuthToken } from './authToken'; diff --git a/src/react/all-types-export.ts b/src/react/all-types-export.ts index edf35b59..eee507bf 100644 --- a/src/react/all-types-export.ts +++ b/src/react/all-types-export.ts @@ -59,6 +59,7 @@ export { resetCachedAuthToken, UIPassthroughEvent, DataPanelCustomColumnGroupsAccordionState, + InterceptedApiType, CustomActionsPosition, CustomActionTarget, } from '../index'; diff --git a/src/types.ts b/src/types.ts index 7a00af03..ff59a09a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -329,7 +329,7 @@ export interface CustomisationsInterface { * if a trusted authentication server is used. * @group Authentication / Init */ -export interface EmbedConfig { +export interface EmbedConfig extends InterceptV2Flags { /** * The ThoughtSpot cluster hostname or IP address. */ @@ -739,7 +739,7 @@ export interface FrameParams { /** * The common configuration object for an embedded view. */ -export interface BaseViewConfig { +export interface BaseViewConfig extends InterceptV2Flags { /** * @hidden */ @@ -2966,6 +2966,21 @@ export enum EmbedEvent { * @version SDK: 1.41.0 | ThoughtSpot: 10.12.0.cl */ OrgSwitched = 'orgSwitched', + /** + * Emitted when the user intercepts a URL. + * + * Supported on all embed types. + * + * @example + * ```js + * embed.on(EmbedEvent.ApiIntercept, (payload) => { + * console.log('payload', payload); + * }) + * ``` + * + * @version SDK: 1.42.0 | ThoughtSpot: 10.14.0.cl + */ + ApiIntercept = 'ApiIntercept', } /** @@ -5963,3 +5978,67 @@ export interface DefaultAppInitData { hiddenListColumns: ListPageColumns[]; customActions: CustomAction[]; } + +/** + * Enum for the type of API intercepted + */ +export enum InterceptedApiType { + /** + * The apis that are use to get the metadata for the embed + */ + METADATA = 'METADATA', + /** + * The apis that are use to get the data for the embed + */ + DATA = 'DATA', + /** + * This will intercept all the apis + */ + ALL = 'ALL', +} + + +export type InterceptV2Flags = { + /** + * Enable intercepting the apis + * + * @example + * ```js + * const embed = new LiveboardEmbed('#embed', { + * ...viewConfig, + * enableApiIntercept: true, + * interceptUrls: [InterceptedApiType.DATA], + * }) + * ``` + * + */ + enableApiIntercept?: boolean; + /** + * The apis to intercept + * + * @example + * ```js + * const embed = new LiveboardEmbed('#embed', { + * ...viewConfig, + * enableApiIntercept: true, + * interceptUrls: [InterceptedApiType.DATA], + * }) + * ``` + */ + interceptUrls?: (string | InterceptedApiType)[]; + /** + * The timeout for the intercept, default is 30000ms + * the api will error out if the timeout is reached + * + * @example + * ```js + * const embed = new LiveboardEmbed('#embed', { + * ...viewConfig, + * enableApiIntercept: true, + * interceptUrls: [InterceptedApiType.ALL], + * interceptTimeout: 1000, + * }) + * ``` + */ + interceptTimeout?: number; +} diff --git a/src/utils/processData.ts b/src/utils/processData.ts index 2d06c5ba..74432bf9 100644 --- a/src/utils/processData.ts +++ b/src/utils/processData.ts @@ -10,9 +10,9 @@ import { AuthType, CustomActionPayload, EmbedEvent } from '../types'; import { AnswerService } from './graphql/answerService/answerService'; import { resetCachedAuthToken } from '../authToken'; import { ERROR_MESSAGE } from '../errors'; -import { logger } from '../utils/logger'; import { handleExitPresentMode } from '../utils'; import { resetCachedPreauthInfo, resetCachedSessionInfo } from './sessionInfoService'; +import { processApiIntercept } from '../api-intercept'; /** * Process the ExitPresentMode event and handle default fullscreen exit @@ -21,7 +21,7 @@ import { resetCachedPreauthInfo, resetCachedSessionInfo } from './sessionInfoSer function processExitPresentMode(e: any) { const embedConfig = getEmbedConfig(); const disableFullscreenPresentation = embedConfig?.disableFullscreenPresentation ?? true; - + if (!disableFullscreenPresentation) { handleExitPresentMode(); } @@ -103,7 +103,7 @@ export function processAuthFailure(e: any, containerEl: Element) { const { loginFailedMessage, authType, disableLoginFailurePage, autoLogin, } = getEmbedConfig(); - + const isEmbeddedSSO = authType === AuthType.EmbeddedSSO; const isTrustedAuth = authType === AuthType.TrustedAuthToken || authType === AuthType.TrustedAuthTokenCookieless; const isEmbeddedSSOInfoFailure = isEmbeddedSSO && e?.data?.type === AuthFailureType.UNAUTHENTICATED_FAILURE; @@ -144,26 +144,26 @@ function processAuthLogout(e: any, containerEl: Element) { */ export function processEventData( type: EmbedEvent, - e: any, + eventData: any, thoughtSpotHost: string, containerEl: Element, ): any { switch (type) { case EmbedEvent.CustomAction: - return processCustomAction(e, thoughtSpotHost); + return processCustomAction(eventData, thoughtSpotHost); case EmbedEvent.AuthInit: - return processAuthInit(e); + return processAuthInit(eventData); case EmbedEvent.NoCookieAccess: - return processNoCookieAccess(e, containerEl); + return processNoCookieAccess(eventData, containerEl); case EmbedEvent.AuthFailure: - return processAuthFailure(e, containerEl); + return processAuthFailure(eventData, containerEl); case EmbedEvent.AuthLogout: - return processAuthLogout(e, containerEl); + return processAuthLogout(eventData, containerEl); case EmbedEvent.ExitPresentMode: - return processExitPresentMode(e); + return processExitPresentMode(eventData); case EmbedEvent.CLEAR_INFO_CACHE: return processClearInfoCache(); default: } - return e; + return eventData; } From ed4abcb103b748d309ca2b3ca9d21471d1f90bdf Mon Sep 17 00:00:00 2001 From: sastaachar Date: Fri, 10 Oct 2025 15:52:16 +0530 Subject: [PATCH 02/28] SCAL-269016 : logger --- src/api-intercept.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api-intercept.ts b/src/api-intercept.ts index 3b952449..97e5e7c6 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -1,7 +1,7 @@ import { getThoughtSpotHost } from "./config"; import { getEmbedConfig } from "./embed/embedConfig"; import { InterceptedApiType, BaseViewConfig, EmbedConfig, InterceptV2Flags, EmbedEvent } from "./types"; - +import { logger } from "./utils/logger"; const defaultUrls: Record, string[]> = { From 1502a7a2d2585b202f8b62d4f0a90f144ec34746 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Fri, 10 Oct 2025 15:58:09 +0530 Subject: [PATCH 03/28] SCAL-269016 : sepration --- src/api-intercept.ts | 19 +++++++++++-------- src/types.ts | 6 +++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/api-intercept.ts b/src/api-intercept.ts index 97e5e7c6..459337ba 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -9,9 +9,11 @@ const defaultUrls: Record, s '/prism/?op=CreateAnswerSession', '/prism/?op=GetV2SourceDetail', ] as string[], - [InterceptedApiType.DATA]: [ + [InterceptedApiType.ANSWER_DATA]: [ '/prism/?op=GetChartWithData', '/prism/?op=GetTableWithHeadlineData', + ] as string[], + [InterceptedApiType.LIVEBOARD_DATA]: [ '/prism/?op=LoadContextBook' ] as string[], }; @@ -31,13 +33,14 @@ interface LegacyInterceptFlags { isOnBeforeGetVizDataInterceptEnabled: boolean; } -const processInterceptUrls = (combinedUrls: (string | InterceptedApiType)[]) => { +const processInterceptUrls = (interceptUrls: (string | InterceptedApiType)[]) => { + let processedUrls = [...interceptUrls]; Object.entries(defaultUrls).forEach(([apiType, apiTypeUrls]) => { - if (!combinedUrls.includes(apiType)) return; - combinedUrls = combinedUrls.filter(url => url !== apiType); - combinedUrls = [...combinedUrls, ...apiTypeUrls]; + if (!processedUrls.includes(apiType)) return; + processedUrls = processedUrls.filter(url => url !== apiType); + processedUrls = [...processedUrls, ...apiTypeUrls]; }) - return combinedUrls.map(url => formatInterceptUrl(url)); + return processedUrls.map(url => formatInterceptUrl(url)); } export const getInterceptInitData = (embedConfig: EmbedConfig, viewConfig: BaseViewConfig): InterceptV2Flags => { @@ -50,7 +53,7 @@ export const getInterceptInitData = (embedConfig: EmbedConfig, viewConfig: BaseV const combinedUrls = [...(embedConfig.interceptUrls || []), ...(viewConfig.interceptUrls || [])]; if ((viewConfig as LegacyInterceptFlags).isOnBeforeGetVizDataInterceptEnabled) { - combinedUrls.push(InterceptedApiType.DATA); + combinedUrls.push(InterceptedApiType.ANSWER_DATA); } const shouldInterceptAll = combinedUrls.includes(InterceptedApiType.ALL); @@ -102,7 +105,7 @@ export const handleInterceptEvent = async (params: { eventData: any, executeEven const sessionId = init?.body?.variables?.session?.sessionId; const vizId = init?.body?.variables?.contextBookId; - if (defaultUrls.DATA.includes(requestUrl) && (viewConfig as LegacyInterceptFlags).isOnBeforeGetVizDataInterceptEnabled) { + if (defaultUrls.ANSWER_DATA.includes(requestUrl) && (viewConfig as LegacyInterceptFlags).isOnBeforeGetVizDataInterceptEnabled) { const answerTml = await getUnsavedAnswerTml({ sessionId, vizId }); executeEvent(EmbedEvent.OnBeforeGetVizDataIntercept, { data: { data: answerTml } }); } diff --git a/src/types.ts b/src/types.ts index ff59a09a..9cd055e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5990,11 +5990,15 @@ export enum InterceptedApiType { /** * The apis that are use to get the data for the embed */ - DATA = 'DATA', + ANSWER_DATA = 'ANSWER_DATA', /** * This will intercept all the apis */ ALL = 'ALL', + /** + * The apis that are use to get the data for the liveboard + */ + LIVEBOARD_DATA = 'LIVEBOARD_DATA', } From 7e5b7837b60cb366d98a52fa9119b4c7465c2864 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Tue, 14 Oct 2025 09:05:53 +0530 Subject: [PATCH 04/28] SCAL-269016 : body --- src/api-intercept.ts | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/api-intercept.ts b/src/api-intercept.ts index 459337ba..b5309bb3 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -118,17 +118,20 @@ export const processLegacyInterceptResponse = (payload: any) => { const title = payload?.data?.errorText; const desc = payload?.data?.errorDescription; - const payloadToSend = [{ - data: {}, - errors: [ - { - errorObj: { - title, - desc + const payloadToSend = { + execute: payload?.data?.execute, + body: { + errors: [ + { + errorObj: { + title, + desc + } } - } - ], - }]; + ], + }, + status: 200, + }; - return payloadToSend; + return { data: payloadToSend }; } From 98d70797518179196bdaff0c597555c569b63461 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Tue, 14 Oct 2025 20:40:21 +0530 Subject: [PATCH 05/28] SCAL-269016 : res --- src/api-intercept.ts | 22 ++++++++++++---------- src/utils/processData.spec.ts | 1 - 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/api-intercept.ts b/src/api-intercept.ts index b5309bb3..a2cb7a22 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -120,17 +120,19 @@ export const processLegacyInterceptResponse = (payload: any) => { const payloadToSend = { execute: payload?.data?.execute, - body: { - errors: [ - { - errorObj: { - title, - desc + response: { + body: { + errors: [ + { + errorObj: { + title, + desc + } } - } - ], - }, - status: 200, + ], + }, + status: 200, + } }; return { data: payloadToSend }; diff --git a/src/utils/processData.spec.ts b/src/utils/processData.spec.ts index beb06d81..5f4dc3c6 100644 --- a/src/utils/processData.spec.ts +++ b/src/utils/processData.spec.ts @@ -1,4 +1,3 @@ -import { disable } from 'mixpanel-browser'; import * as processDataInstance from './processData'; import * as answerServiceInstance from './graphql/answerService/answerService'; import * as auth from '../auth'; From 5cfb62ea9b4e192d6ca9e2e72af2a0d9ff2cadbf Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 15 Oct 2025 16:21:11 +0530 Subject: [PATCH 06/28] SCAL-269016 : update --- src/api-intercept.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/api-intercept.ts b/src/api-intercept.ts index a2cb7a22..f18d8313 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -115,25 +115,22 @@ export const handleInterceptEvent = async (params: { eventData: any, executeEven export const processLegacyInterceptResponse = (payload: any) => { - const title = payload?.data?.errorText; - const desc = payload?.data?.errorDescription; - const payloadToSend = { execute: payload?.data?.execute, response: { body: { errors: [ { - errorObj: { - title, - desc - } - } + title: payload?.data?.errorText, + message: payload?.data?.errorDescription, + isUserError: true, + }, ], + data: {}, }, - status: 200, - } + }, }; + return { data: payloadToSend }; } From f40ad126304a6273c59856a68f34b76523a6e36a Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 15 Oct 2025 23:51:47 +0530 Subject: [PATCH 07/28] SCAL-269016 : minor --- src/api-intercept.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/api-intercept.ts b/src/api-intercept.ts index f18d8313..f6a1aaa6 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -115,14 +115,17 @@ export const handleInterceptEvent = async (params: { eventData: any, executeEven export const processLegacyInterceptResponse = (payload: any) => { + const errorText = payload?.data?.errorText || payload?.data?.error?.errorText; + const errorDescription = payload?.data?.errorDescription || payload?.data?.error?.errorDescription; + const payloadToSend = { execute: payload?.data?.execute, response: { body: { errors: [ { - title: payload?.data?.errorText, - message: payload?.data?.errorDescription, + title: errorText, + message: errorDescription, isUserError: true, }, ], From 3946a661bc19bac4f754586166f3791ff8cdc5bf Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 15 Oct 2025 23:53:42 +0530 Subject: [PATCH 08/28] SCAL-269016 : ok --- src/api-intercept.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api-intercept.ts b/src/api-intercept.ts index f6a1aaa6..311e7a06 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -125,7 +125,7 @@ export const processLegacyInterceptResponse = (payload: any) => { errors: [ { title: errorText, - message: errorDescription, + description: errorDescription, isUserError: true, }, ], From 3de2ba213cd1ab6da45f68c20ba8361cb887db2c Mon Sep 17 00:00:00 2001 From: sastaachar Date: Thu, 23 Oct 2025 12:11:02 +0530 Subject: [PATCH 09/28] SCAL-269016 : minor --- src/api-intercept.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api-intercept.ts b/src/api-intercept.ts index 311e7a06..fd221785 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -12,6 +12,7 @@ const defaultUrls: Record, s [InterceptedApiType.ANSWER_DATA]: [ '/prism/?op=GetChartWithData', '/prism/?op=GetTableWithHeadlineData', + '/prism/?op=GetTableWithData', ] as string[], [InterceptedApiType.LIVEBOARD_DATA]: [ '/prism/?op=LoadContextBook' From db8c0c527c966bdb32caeefb3bd5b9ffb94fbf1c Mon Sep 17 00:00:00 2001 From: sastaachar Date: Tue, 28 Oct 2025 15:21:29 +0530 Subject: [PATCH 10/28] SCAL-269016 : smal --- src/api-intercept.ts | 33 +++++++++++++++++++++++++++------ src/embed/ts-embed.ts | 13 ++++++------- src/utils/logger.ts | 3 +-- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/api-intercept.ts b/src/api-intercept.ts index fd221785..9e9b0f9e 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -3,7 +3,6 @@ import { getEmbedConfig } from "./embed/embedConfig"; import { InterceptedApiType, BaseViewConfig, EmbedConfig, InterceptV2Flags, EmbedEvent } from "./types"; import { logger } from "./utils/logger"; - const defaultUrls: Record, string[]> = { [InterceptedApiType.METADATA]: [ '/prism/?op=CreateAnswerSession', @@ -43,8 +42,8 @@ const processInterceptUrls = (interceptUrls: (string | InterceptedApiType)[]) => }) return processedUrls.map(url => formatInterceptUrl(url)); } -export const getInterceptInitData = (embedConfig: EmbedConfig, viewConfig: BaseViewConfig): InterceptV2Flags => { - +export const getInterceptInitData = (viewConfig: BaseViewConfig): InterceptV2Flags => { + const embedConfig = getEmbedConfig(); const enableApiIntercept = (embedConfig.enableApiIntercept || viewConfig.enableApiIntercept) && (viewConfig.enableApiIntercept !== false); if (!enableApiIntercept) return { @@ -69,6 +68,15 @@ export const getInterceptInitData = (embedConfig: EmbedConfig, viewConfig: BaseV }; } +const parseJson = (jsonString: string): [any, Error | null] => { + try { + const json = JSON.parse(jsonString); + return [json, null]; + } catch (error) { + return [null, error]; + } +} + /** * * @param fetchInit @@ -76,9 +84,17 @@ export const getInterceptInitData = (embedConfig: EmbedConfig, viewConfig: BaseV const parseInterceptData = (eventDataString: any) => { try { - const { input, init } = JSON.parse(eventDataString); + const [parsedData, error] = parseJson(eventDataString); + if (error) { + return [null, error]; + } + + const { input, init } = parsedData; - init.body = JSON.parse(init.body); + const [parsedBody, bodyParseError] = parseJson(init.body); + if (!bodyParseError) { + init.body = parsedBody; + } const parsedInit = { input, init }; return [parsedInit, null]; @@ -87,7 +103,12 @@ const parseInterceptData = (eventDataString: any) => { } } -export const handleInterceptEvent = async (params: { eventData: any, executeEvent: (eventType: EmbedEvent, data: any) => void, embedConfig: EmbedConfig, viewConfig: BaseViewConfig, getUnsavedAnswerTml: (props: { sessionId?: string, vizId?: string }) => Promise<{ tml: string }> }) => { +export const handleInterceptEvent = async (params: { + eventData: any, + executeEvent: (eventType: EmbedEvent, data: any) => void, + viewConfig: BaseViewConfig, + getUnsavedAnswerTml: (props: { sessionId?: string, vizId?: string }) => Promise<{ tml: string }> +}) => { const { eventData, executeEvent, viewConfig, getUnsavedAnswerTml } = params; diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index a4545826..0c3b799f 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -349,16 +349,15 @@ export class TsEmbed { this.isPreRendered ? this.preRenderWrapper : this.el, ); - const executeEvent = (_eventType: EmbedEvent, data: any) => { - this.executeCallbacks(_eventType, data, eventPort); - } - if (eventType === EmbedEvent.ApiIntercept && this.viewConfig.enableApiIntercept) { + const executeEvent = (_eventType: EmbedEvent, data: any) => { + this.executeCallbacks(_eventType, data, eventPort); + } const getUnsavedAnswerTml = async (props: { sessionId?: string, vizId?: string }) => { const response = await this.triggerUIPassThrough(UIPassthroughEvent.GetUnsavedAnswerTML, props); return response[0]?.value; } - handleInterceptEvent({ eventData: processedEventData, executeEvent, embedConfig: this.embedConfig, viewConfig: this.viewConfig, getUnsavedAnswerTml }); + handleInterceptEvent({ eventData: processedEventData, executeEvent, viewConfig: this.viewConfig, getUnsavedAnswerTml }); return; } @@ -464,7 +463,7 @@ export class TsEmbed { this.embedConfig.customVariablesForThirdPartyTools || {}, hiddenListColumns: this.viewConfig.hiddenListColumns || [], customActions: customActionsResult.actions, - ...getInterceptInitData(this.embedConfig, this.viewConfig), + ...getInterceptInitData(this.viewConfig), }; return baseInitData; @@ -1047,7 +1046,7 @@ export class TsEmbed { protected createEmbedEventResponder = (eventPort: MessagePort | void, eventType: EmbedEvent) => { - const { enableApiIntercept } = getInterceptInitData(this.embedConfig, this.viewConfig); + const { enableApiIntercept } = getInterceptInitData(this.viewConfig); if (eventType === EmbedEvent.OnBeforeGetVizDataIntercept && enableApiIntercept) { return (payload: any) => { const payloadToSend = processLegacyInterceptResponse(payload); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index d52c6730..86f10dc9 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,4 +1,3 @@ -import { isUndefined } from '../utils'; import { LogLevel } from '../types'; const logFunctions: { @@ -42,7 +41,7 @@ class Logger { public canLog(logLevel: LogLevel): boolean { if (logLevel === LogLevel.SILENT) return false; - if (!isUndefined(globalLogLevelOverride)) { + if (globalLogLevelOverride !== undefined) { return compareLogLevels(globalLogLevelOverride, logLevel) >= 0; } return compareLogLevels(this.logLevel, logLevel) >= 0; From bca87238c323cfe53dc32dc9e4642a9fcaa50aad Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 17:34:32 +0530 Subject: [PATCH 11/28] SCAL-269016 : true by default --- src/api-intercept.ts | 97 +++++++++++++++++++++++++++++-------------- src/embed/app.ts | 17 -------- src/embed/search.ts | 15 ------- src/embed/ts-embed.ts | 32 +++++++++----- src/types.ts | 64 ++++++++++++++-------------- 5 files changed, 117 insertions(+), 108 deletions(-) diff --git a/src/api-intercept.ts b/src/api-intercept.ts index 9e9b0f9e..8d2466f1 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -1,19 +1,16 @@ import { getThoughtSpotHost } from "./config"; import { getEmbedConfig } from "./embed/embedConfig"; -import { InterceptedApiType, BaseViewConfig, EmbedConfig, InterceptV2Flags, EmbedEvent } from "./types"; +import { InterceptedApiType, BaseViewConfig, EmbedConfig, ApiInterceptFlags, EmbedEvent } from "./types"; +import { embedEventStatus } from "./utils"; import { logger } from "./utils/logger"; -const defaultUrls: Record, string[]> = { - [InterceptedApiType.METADATA]: [ - '/prism/?op=CreateAnswerSession', - '/prism/?op=GetV2SourceDetail', - ] as string[], - [InterceptedApiType.ANSWER_DATA]: [ +const DefaultInterceptUrlsMap: Record, string[]> = { + [InterceptedApiType.AnswerData]: [ '/prism/?op=GetChartWithData', '/prism/?op=GetTableWithHeadlineData', '/prism/?op=GetTableWithData', ] as string[], - [InterceptedApiType.LIVEBOARD_DATA]: [ + [InterceptedApiType.LiveboardData]: [ '/prism/?op=LoadContextBook' ] as string[], }; @@ -32,39 +29,43 @@ export const processApiIntercept = async (eventData: any) => { interface LegacyInterceptFlags { isOnBeforeGetVizDataInterceptEnabled: boolean; } - +/** + * Converts user passed url values to proper urls + * [ANSER_DATA] => ['https://host/pris/op?=op'] + * @param interceptUrls + * @returns + */ const processInterceptUrls = (interceptUrls: (string | InterceptedApiType)[]) => { let processedUrls = [...interceptUrls]; - Object.entries(defaultUrls).forEach(([apiType, apiTypeUrls]) => { + Object.entries(DefaultInterceptUrlsMap).forEach(([apiType, apiTypeUrls]) => { if (!processedUrls.includes(apiType)) return; processedUrls = processedUrls.filter(url => url !== apiType); processedUrls = [...processedUrls, ...apiTypeUrls]; }) return processedUrls.map(url => formatInterceptUrl(url)); } -export const getInterceptInitData = (viewConfig: BaseViewConfig): InterceptV2Flags => { - const embedConfig = getEmbedConfig(); - const enableApiIntercept = (embedConfig.enableApiIntercept || viewConfig.enableApiIntercept) && (viewConfig.enableApiIntercept !== false); - if (!enableApiIntercept) return { - enableApiIntercept: false, - }; - - const combinedUrls = [...(embedConfig.interceptUrls || []), ...(viewConfig.interceptUrls || [])]; +/** + * Returns the data to be sent to embed to setup intercepts + * the urls to intercept, timeout etc + * @param viewConfig + * @returns + */ +export const getInterceptInitData = (viewConfig: BaseViewConfig): ApiInterceptFlags => { + const combinedUrls = [...(viewConfig.interceptUrls || [])]; if ((viewConfig as LegacyInterceptFlags).isOnBeforeGetVizDataInterceptEnabled) { - combinedUrls.push(InterceptedApiType.ANSWER_DATA); + combinedUrls.push(InterceptedApiType.AnswerData); } const shouldInterceptAll = combinedUrls.includes(InterceptedApiType.ALL); const interceptUrls = shouldInterceptAll ? [InterceptedApiType.ALL] : processInterceptUrls(combinedUrls); - const interceptTimeout = embedConfig.interceptTimeout || viewConfig.interceptTimeout; + const interceptTimeout = viewConfig.interceptTimeout; return { interceptUrls, interceptTimeout, - enableApiIntercept, }; } @@ -103,11 +104,29 @@ const parseInterceptData = (eventDataString: any) => { } } -export const handleInterceptEvent = async (params: { - eventData: any, - executeEvent: (eventType: EmbedEvent, data: any) => void, - viewConfig: BaseViewConfig, - getUnsavedAnswerTml: (props: { sessionId?: string, vizId?: string }) => Promise<{ tml: string }> +const getUrlType = (url: string) => { + for (const [apiType, apiTypeUrls] of Object.entries(DefaultInterceptUrlsMap)) { + if (apiTypeUrls.includes(url)) return apiType as InterceptedApiType; + } + // TODO: have a unknown type maybe ?? + return InterceptedApiType.ALL; +} + +/** + * Handle Api intercept event and simulate legacy onBeforeGetVizDataIntercept event + * + * embed sends -> ApiIntercept -> we send + * ApiIntercept + * OnBeforeGetVizDataIntercept (if url is part of DefaultUrlMap.AnswerData) + * + * @param params + * @returns + */ +export const handleInterceptEvent = async (params: { + eventData: any, + executeEvent: (eventType: EmbedEvent, data: any) => void, + viewConfig: BaseViewConfig, + getUnsavedAnswerTml: (props: { sessionId?: string, vizId?: string }) => Promise<{ tml: string }> }) => { const { eventData, executeEvent, viewConfig, getUnsavedAnswerTml } = params; @@ -122,23 +141,37 @@ export const handleInterceptEvent = async (params: { return; } - const { input: requestUrl, init } = interceptData; + const { input: requestUrl, init } = interceptData; + const sessionId = init?.body?.variables?.session?.sessionId; const vizId = init?.body?.variables?.contextBookId; - if (defaultUrls.ANSWER_DATA.includes(requestUrl) && (viewConfig as LegacyInterceptFlags).isOnBeforeGetVizDataInterceptEnabled) { + const answerDataUrls = DefaultInterceptUrlsMap[InterceptedApiType.AnswerData]; + const legacyInterceptEnabled = viewConfig.isOnBeforeGetVizDataInterceptEnabled; + const isAnswerDataUrl = answerDataUrls.includes(requestUrl); + const sendLegacyIntercept = isAnswerDataUrl && legacyInterceptEnabled; + if (sendLegacyIntercept) { const answerTml = await getUnsavedAnswerTml({ sessionId, vizId }); - executeEvent(EmbedEvent.OnBeforeGetVizDataIntercept, { data: { data: answerTml } }); + // Build the legacy payload for backwards compatibility + const legacyPayload = { + data: { + data: { answer: answerTml }, + status: embedEventStatus.END, + type: EmbedEvent.OnBeforeGetVizDataIntercept + } + } + executeEvent(EmbedEvent.OnBeforeGetVizDataIntercept, legacyPayload); } - executeEvent(EmbedEvent.ApiIntercept, interceptData); + const urlType = getUrlType(requestUrl); + executeEvent(EmbedEvent.ApiIntercept, { ...interceptData, urlType }); } export const processLegacyInterceptResponse = (payload: any) => { - const errorText = payload?.data?.errorText || payload?.data?.error?.errorText; - const errorDescription = payload?.data?.errorDescription || payload?.data?.error?.errorDescription; + const errorText = payload?.data?.error?.errorText; + const errorDescription = payload?.data?.error?.errorDescription; const payloadToSend = { execute: payload?.data?.execute, diff --git a/src/embed/app.ts b/src/embed/app.ts index fedb6410..189f0835 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -19,7 +19,6 @@ import { AllEmbedViewConfig, } from '../types'; import { V1Embed } from './ts-embed'; -import { getInterceptInitData } from '../api-intercept'; /** * Pages within the ThoughtSpot app that can be embedded. @@ -513,11 +512,6 @@ export interface AppViewConfig extends AllEmbedViewConfig { * ``` */ dataPanelCustomGroupsAccordionInitialState?: DataPanelCustomColumnGroupsAccordionState; - /** - * Flag that allows using `EmbedEvent.OnBeforeGetVizDataIntercept`. - * @version SDK : 1.29.0 | ThoughtSpot: 10.1.0.cl - */ - isOnBeforeGetVizDataInterceptEnabled?: boolean; /** * Flag to use home page search bar mode * @@ -669,8 +663,6 @@ export class AppEmbed extends V1Embed { collapseSearchBarInitially = false, enable2ColumnLayout, enableCustomColumnGroups = false, - isOnBeforeGetVizDataInterceptEnabled = false, - dataPanelCustomGroupsAccordionInitialState = DataPanelCustomColumnGroupsAccordionState.EXPAND_ALL, collapseSearchBar = true, isLiveboardCompactHeaderEnabled = false, @@ -753,15 +745,6 @@ export class AppEmbed extends V1Embed { params[Param.enableAskSage] = enableAskSage; } - const { enableApiIntercept } = getInterceptInitData(this.embedConfig, this.viewConfig); - - if (isOnBeforeGetVizDataInterceptEnabled && !enableApiIntercept) { - - params[ - Param.IsOnBeforeGetVizDataInterceptEnabled - ] = isOnBeforeGetVizDataInterceptEnabled; - } - if (homePageSearchBarMode) { params[Param.HomePageSearchBarMode] = homePageSearchBarMode; } diff --git a/src/embed/search.ts b/src/embed/search.ts index 186271d2..e572b9ac 100644 --- a/src/embed/search.ts +++ b/src/embed/search.ts @@ -277,13 +277,6 @@ export interface SearchViewConfig * @deprecated Use {@link collapseSearchBar} instead */ collapseSearchBarInitially?: boolean; - /** - * Flag to enable onBeforeSearchExecute Embed Event - * - * Supported embed types: `SearchEmbed` - * @version: SDK: 1.29.0 | ThoughtSpot: 10.1.0.cl - */ - isOnBeforeGetVizDataInterceptEnabled?: boolean; /** * This controls the initial behaviour of custom column groups accordion. * It takes DataPanelCustomColumnGroupsAccordionState enum values as input. @@ -398,8 +391,6 @@ export class SearchEmbed extends TsEmbed { runtimeParameters, collapseSearchBarInitially = false, enableCustomColumnGroups = false, - isOnBeforeGetVizDataInterceptEnabled = false, - dataPanelCustomGroupsAccordionInitialState = DataPanelCustomColumnGroupsAccordionState.EXPAND_ALL, focusSearchBarOnRender = true, excludeRuntimeParametersfromURL, @@ -443,12 +434,6 @@ export class SearchEmbed extends TsEmbed { queryParams[Param.HideSearchBar] = true; } - const { enableApiIntercept } = getInterceptInitData(this.embedConfig, this.viewConfig); - if (isOnBeforeGetVizDataInterceptEnabled && !enableApiIntercept) { - - queryParams[Param.IsOnBeforeGetVizDataInterceptEnabled] = isOnBeforeGetVizDataInterceptEnabled; - } - if (!focusSearchBarOnRender) { queryParams[Param.FocusSearchBarOnRender] = focusSearchBarOnRender; } diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 0c3b799f..6e62c68c 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -337,6 +337,17 @@ export class TsEmbed { this.subscribedListeners.offline = offlineEventListener; } + private handleApiInterceptEvent({ eventData, eventPort }: { eventData: any, eventPort: MessagePort | void }) { + const executeEvent = (_eventType: EmbedEvent, data: any) => { + this.executeCallbacks(_eventType, data, eventPort); + } + const getUnsavedAnswerTml = async (props: { sessionId?: string, vizId?: string }) => { + const response = await this.triggerUIPassThrough(UIPassthroughEvent.GetUnsavedAnswerTML, props); + return response[0]?.value; + } + handleInterceptEvent({ eventData, executeEvent, viewConfig: this.viewConfig, getUnsavedAnswerTml }); + } + private messageEventListener = async (event: MessageEvent) => { const eventType = this.getEventType(event); const eventPort = this.getEventPort(event); @@ -349,15 +360,8 @@ export class TsEmbed { this.isPreRendered ? this.preRenderWrapper : this.el, ); - if (eventType === EmbedEvent.ApiIntercept && this.viewConfig.enableApiIntercept) { - const executeEvent = (_eventType: EmbedEvent, data: any) => { - this.executeCallbacks(_eventType, data, eventPort); - } - const getUnsavedAnswerTml = async (props: { sessionId?: string, vizId?: string }) => { - const response = await this.triggerUIPassThrough(UIPassthroughEvent.GetUnsavedAnswerTML, props); - return response[0]?.value; - } - handleInterceptEvent({ eventData: processedEventData, executeEvent, viewConfig: this.viewConfig, getUnsavedAnswerTml }); + if (eventType === EmbedEvent.ApiIntercept) { + this.handleApiInterceptEvent({ eventData, eventPort }); return; } @@ -1044,10 +1048,16 @@ export class TsEmbed { this.iFrame.style.height = getCssDimension(height); } + /** + * We can process the customer given payload before sending it to the embed port + * Embed event handler -> responder -> createEmbedEventResponder -> send response + * @param eventPort The event port for a specific MessageChannel + * @param eventType The event type + * @returns + */ protected createEmbedEventResponder = (eventPort: MessagePort | void, eventType: EmbedEvent) => { - const { enableApiIntercept } = getInterceptInitData(this.viewConfig); - if (eventType === EmbedEvent.OnBeforeGetVizDataIntercept && enableApiIntercept) { + if (eventType === EmbedEvent.OnBeforeGetVizDataIntercept) { return (payload: any) => { const payloadToSend = processLegacyInterceptResponse(payload); this.triggerEventOnPort(eventPort, payloadToSend); diff --git a/src/types.ts b/src/types.ts index 9cd055e9..702eef73 100644 --- a/src/types.ts +++ b/src/types.ts @@ -329,7 +329,7 @@ export interface CustomisationsInterface { * if a trusted authentication server is used. * @group Authentication / Init */ -export interface EmbedConfig extends InterceptV2Flags { +export interface EmbedConfig { /** * The ThoughtSpot cluster hostname or IP address. */ @@ -739,7 +739,7 @@ export interface FrameParams { /** * The common configuration object for an embedded view. */ -export interface BaseViewConfig extends InterceptV2Flags { +export interface BaseViewConfig extends ApiInterceptFlags { /** * @hidden */ @@ -2732,21 +2732,24 @@ export enum EmbedEvent { * Prerequisite: Set `isOnBeforeGetVizDataInterceptEnabled` to `true` * for this embed event to get emitted. - * @param: payload - * @param: responder + * @param:payload The payload received from the embed related to the Data API call. + * @param:responder * Contains elements that lets developers define whether ThoughtSpot * should run the search, and if not, what error message * should be shown to the user. * - * execute: When execute returns `true`, the search will be run. + * `execute` - When execute returns `true`, the search will be run. * When execute returns `false`, the search will not be executed. * - * error: Developers can customize the error message text when `execute` - * returns `false` using the error parameter in responder. + * `error` - Developers can customize the error message text when `execute` + * is `false` using the `errorText` and `errorDescription` parameters in responder. + * + * `errorText` - The error message text to be shown to the user. + * `errorDescription (ThoughtSpot: 10.15.0.cl and above)` - The error description to be shown to the user. * @version SDK : 1.29.0 | ThoughtSpot: 10.3.0.cl * @example *```js - * .on(EmbedEvent.OnBeforeGetVizDataIntercept, + * embed.on(EmbedEvent.OnBeforeGetVizDataIntercept, * (payload, responder) => { * responder({ * data: { @@ -2762,7 +2765,7 @@ export enum EmbedEvent { * ``` * *```js - * .on(EmbedEvent.OnBeforeGetVizDataIntercept, + * embed.on(EmbedEvent.OnBeforeGetVizDataIntercept, * (payload, responder) => { * const query = payload.data.data.answer.search_query * responder({ @@ -2773,7 +2776,8 @@ export enum EmbedEvent { * error: { * //Provide a custom error message to explain to your end user * // why their search did not run, and which searches are accepted by your custom logic. - * errorText: "You can't use this query :" + query + ". + * errorText: "Error Occurred", + * errorDescription: "You can't use this query :" + query + ". * The 'sales' measures can never be used at the 'county' level. * Please try another measure, or remove 'county' from your search." * } @@ -4290,7 +4294,7 @@ export enum HostEvent { * The different visual modes that the data sources panel within * search could appear in, such as hidden, collapsed, or expanded. */ - + export enum DataSourceVisualMode { /** * The data source panel is hidden. @@ -4310,7 +4314,7 @@ export enum DataSourceVisualMode { * The query params passed down to the embedded ThoughtSpot app * containing configuration and/or visual information. */ - + export enum Param { EmbedApp = 'embedApp', DataSources = 'dataSources', @@ -4467,7 +4471,7 @@ export enum Param { * ``` * See also link:https://developers.thoughtspot.com/docs/actions[Action IDs in the SDK] */ - + export enum Action { /** * The **Save** action on an Answer or Liveboard. @@ -5983,14 +5987,10 @@ export interface DefaultAppInitData { * Enum for the type of API intercepted */ export enum InterceptedApiType { - /** - * The apis that are use to get the metadata for the embed - */ - METADATA = 'METADATA', /** * The apis that are use to get the data for the embed */ - ANSWER_DATA = 'ANSWER_DATA', + AnswerData = 'AnswerData', /** * This will intercept all the apis */ @@ -5998,25 +5998,19 @@ export enum InterceptedApiType { /** * The apis that are use to get the data for the liveboard */ - LIVEBOARD_DATA = 'LIVEBOARD_DATA', + LiveboardData = 'LiveboardData', } -export type InterceptV2Flags = { +export type ApiInterceptFlags = { /** - * Enable intercepting the apis - * - * @example - * ```js - * const embed = new LiveboardEmbed('#embed', { - * ...viewConfig, - * enableApiIntercept: true, - * interceptUrls: [InterceptedApiType.DATA], - * }) - * ``` - * - */ - enableApiIntercept?: boolean; + * Flag that allows using `EmbedEvent.OnBeforeGetVizDataIntercept`. + * + * Can be used for Serach and App Embed from SDK 1.29.0 + * + * @version SDK : 1.44.0 | ThoughtSpot: 10.15.0.cl + */ + isOnBeforeGetVizDataInterceptEnabled?: boolean; /** * The apis to intercept * @@ -6028,6 +6022,8 @@ export type InterceptV2Flags = { * interceptUrls: [InterceptedApiType.DATA], * }) * ``` + * + * @version SDK : 1.44.0 | ThoughtSpot: 10.15.0.cl */ interceptUrls?: (string | InterceptedApiType)[]; /** @@ -6043,6 +6039,8 @@ export type InterceptV2Flags = { * interceptTimeout: 1000, * }) * ``` + * + * @version SDK : 1.44.0 | ThoughtSpot: 10.15.0.cl */ interceptTimeout?: number; } From 23d2c16c42e29a3f966a529102be7153590e94d3 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 18:08:35 +0530 Subject: [PATCH 12/28] SCAL-269016 : ok --- src/api-intercept.ts | 15 ++++++++++ src/embed/ts-embed.ts | 19 +++++++----- src/types.ts | 69 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/api-intercept.ts b/src/api-intercept.ts index 8d2466f1..b6eee3c7 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -168,6 +168,21 @@ export const handleInterceptEvent = async (params: { executeEvent(EmbedEvent.ApiIntercept, { ...interceptData, urlType }); } +/** + * Support both the legacy and new format of the api intercept response + * @param payload + * @returns + */ +export const processApiInterceptResponse = (payload: any) => { + const isLegacyFormat = payload?.data?.error; + + if (isLegacyFormat) { + return processLegacyInterceptResponse(payload); + } + + return payload; +} + export const processLegacyInterceptResponse = (payload: any) => { const errorText = payload?.data?.error?.errorText; diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 6e62c68c..e00f47b4 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -71,7 +71,7 @@ import { getEmbedConfig } from './embedConfig'; import { ERROR_MESSAGE } from '../errors'; import { getPreauthInfo } from '../utils/sessionInfoService'; import { HostEventClient } from './hostEventClient/host-event-client'; -import { getInterceptInitData, handleInterceptEvent, processLegacyInterceptResponse } from '../api-intercept'; +import { getInterceptInitData, handleInterceptEvent, processApiInterceptResponse, processLegacyInterceptResponse } from '../api-intercept'; const { version } = pkgInfo; @@ -1057,15 +1057,18 @@ export class TsEmbed { */ protected createEmbedEventResponder = (eventPort: MessagePort | void, eventType: EmbedEvent) => { - if (eventType === EmbedEvent.OnBeforeGetVizDataIntercept) { - return (payload: any) => { - const payloadToSend = processLegacyInterceptResponse(payload); - this.triggerEventOnPort(eventPort, payloadToSend); + const getPayloadToSend = (payload: any) => { + if (eventType === EmbedEvent.OnBeforeGetVizDataIntercept) { + return processLegacyInterceptResponse(payload); } - } - + if (eventType === EmbedEvent.ApiIntercept) { + return processApiInterceptResponse(payload); + } + return payload; + } return (payload: any) => { - this.triggerEventOnPort(eventPort, payload); + const payloadToSend = getPayloadToSend(payload); + this.triggerEventOnPort(eventPort, payloadToSend); } } diff --git a/src/types.ts b/src/types.ts index 702eef73..bbbba52d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2977,11 +2977,75 @@ export enum EmbedEvent { * * @example * ```js - * embed.on(EmbedEvent.ApiIntercept, (payload) => { + * embed.on(EmbedEvent.ApiIntercept, (payload, responder) => { * console.log('payload', payload); + * responder({ + * data: { + * execute: false, + * error: { + * errorText: 'Error Occurred', + * } + * } + * }) + * }) + * ``` + * + * ```js + * embed.on(EmbedEvent.ApiIntercept, (payload, responder) => { + * console.log('payload', payload); + * responder({ + * data: { + * execute: false, + * error: { + * errorText: 'Error Occurred', + * } + * } + * }) * }) * ``` * + * ```js + * // We can also send a response for the intercepted api + * embed.on(EmbedEvent.ApiIntercept, (payload, responder) => { + * console.log('payload', payload); + * responder({ + * data: { + * execute: false, + * response: { + * body: { + * data: { + * // Some api response + * }, + * } + * } + * } + * }) + * }) + * + * // here embed will use the response from the responder as the response for the api + * ``` + * + * ```js + * // We can also send error in response for the intercepted api + * embed.on(EmbedEvent.ApiIntercept, (payload, responder) => { + * console.log('payload', payload); + * responder({ + * data: { + * execute: false, + * response: { + * body: { + * errors: [{ + * title: 'Error Occurred', + * description: 'Error Description', + * isUserError: true, + * }], + * data: {}, + * }, + * } + * } + * }) + * }) + * ``` * @version SDK: 1.42.0 | ThoughtSpot: 10.14.0.cl */ ApiIntercept = 'ApiIntercept', @@ -6012,7 +6076,8 @@ export type ApiInterceptFlags = { */ isOnBeforeGetVizDataInterceptEnabled?: boolean; /** - * The apis to intercept + * This allows to intercept the urls passed, once intercepted the api will only + * run based on the reponse from the responder of ApiIntercept event. * * @example * ```js From dc6592eb9b1a19eaa61c6854d1ae26ba4567f37c Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 18:14:40 +0530 Subject: [PATCH 13/28] SCAL-269016 : clean --- src/api-intercept.ts | 2 +- src/utils/processData.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api-intercept.ts b/src/api-intercept.ts index b6eee3c7..4621d83e 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -1,6 +1,6 @@ import { getThoughtSpotHost } from "./config"; import { getEmbedConfig } from "./embed/embedConfig"; -import { InterceptedApiType, BaseViewConfig, EmbedConfig, ApiInterceptFlags, EmbedEvent } from "./types"; +import { InterceptedApiType, BaseViewConfig, ApiInterceptFlags, EmbedEvent } from "./types"; import { embedEventStatus } from "./utils"; import { logger } from "./utils/logger"; diff --git a/src/utils/processData.ts b/src/utils/processData.ts index 74432bf9..5f34f006 100644 --- a/src/utils/processData.ts +++ b/src/utils/processData.ts @@ -12,7 +12,6 @@ import { resetCachedAuthToken } from '../authToken'; import { ERROR_MESSAGE } from '../errors'; import { handleExitPresentMode } from '../utils'; import { resetCachedPreauthInfo, resetCachedSessionInfo } from './sessionInfoService'; -import { processApiIntercept } from '../api-intercept'; /** * Process the ExitPresentMode event and handle default fullscreen exit From ef2838aff815a948ab434d0e8142600aa127fdea Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 18:14:49 +0530 Subject: [PATCH 14/28] SCAL-269016 : no cycle --- eslint.config.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index ca936c44..37be1edc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,7 +48,8 @@ export default defineConfig([ maxLength: 90, ignoreUrls: true, }, - ] - } + ], + 'import/no-cycle': "error" + }, }, ]); \ No newline at end of file From 56854c05433be4d9f1746bea8fcd5a76b5661e62 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 18:15:18 +0530 Subject: [PATCH 15/28] SCAL-269016 : clean up --- eslint.config.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 37be1edc..49294f6b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,4 @@ import { defineConfig } from 'eslint/config'; -import globals from 'globals'; import tseslint from 'typescript-eslint'; import pluginReact from 'eslint-plugin-react'; import importPlugin from 'eslint-plugin-import'; From aeb32edcd786ff90051632ae42493ba61b4c4aa6 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 18:57:18 +0530 Subject: [PATCH 16/28] SCAL-269016 : added tests --- src/api-intercept.spec.ts | 886 +++++++++++++++++++++++++++++++++++++ src/api-intercept.ts | 2 +- src/embed/ts-embed.spec.ts | 473 ++++++++++---------- src/types.ts | 2 + 4 files changed, 1119 insertions(+), 244 deletions(-) create mode 100644 src/api-intercept.spec.ts diff --git a/src/api-intercept.spec.ts b/src/api-intercept.spec.ts new file mode 100644 index 00000000..a66b4672 --- /dev/null +++ b/src/api-intercept.spec.ts @@ -0,0 +1,886 @@ +import * as apiIntercept from './api-intercept'; +import * as config from './config'; +import * as embedConfig from './embed/embedConfig'; +import { InterceptedApiType, EmbedEvent, BaseViewConfig } from './types'; +import { embedEventStatus } from './utils'; +import { logger } from './utils/logger'; + +jest.mock('./config'); +jest.mock('./embed/embedConfig'); +jest.mock('./utils/logger'); + +const mockGetThoughtSpotHost = config.getThoughtSpotHost as jest.Mock; +const mockGetEmbedConfig = embedConfig.getEmbedConfig as jest.Mock; +const mockLogger = logger as jest.Mocked; + +describe('api-intercept', () => { + const thoughtSpotHost = 'https://test.thoughtspot.com'; + let originalJsonParse: any; + + beforeAll(() => { + originalJsonParse = JSON.parse; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockGetThoughtSpotHost.mockReturnValue(thoughtSpotHost); + mockGetEmbedConfig.mockReturnValue({}); + // Restore JSON.parse before each test + JSON.parse = originalJsonParse; + }); + + afterEach(() => { + // Ensure JSON.parse is restored after each test + JSON.parse = originalJsonParse; + }); + + describe('processApiIntercept', () => { + it('should parse and return JSON data', async () => { + const eventData = { + data: JSON.stringify({ key: 'value' }) + }; + + const result = await apiIntercept.processApiIntercept(eventData); + + expect(result).toEqual({ key: 'value' }); + }); + + it('should handle complex nested objects', async () => { + const complexData = { + nested: { + deep: { + value: 'test', + array: [1, 2, 3] + } + } + }; + const eventData = { + data: JSON.stringify(complexData) + }; + + const result = await apiIntercept.processApiIntercept(eventData); + + expect(result).toEqual(complexData); + }); + }); + + describe('getInterceptInitData', () => { + it('should return default intercept flags when no intercepts are configured', () => { + const viewConfig: BaseViewConfig = {}; + + const result = apiIntercept.getInterceptInitData(viewConfig); + + expect(result).toEqual({ + interceptUrls: [], + interceptTimeout: undefined, + }); + }); + + it('should expand InterceptedApiType.AnswerData to specific URLs', () => { + const viewConfig: BaseViewConfig = { + interceptUrls: [InterceptedApiType.AnswerData] + }; + + const result = apiIntercept.getInterceptInitData(viewConfig); + + expect(result.interceptUrls).toEqual([ + `${thoughtSpotHost}/prism/?op=GetChartWithData`, + `${thoughtSpotHost}/prism/?op=GetTableWithHeadlineData`, + `${thoughtSpotHost}/prism/?op=GetTableWithData`, + ]); + }); + + it('should expand InterceptedApiType.LiveboardData to specific URLs', () => { + const viewConfig: BaseViewConfig = { + interceptUrls: [InterceptedApiType.LiveboardData] + }; + + const result = apiIntercept.getInterceptInitData(viewConfig); + + expect(result.interceptUrls).toEqual([ + `${thoughtSpotHost}/prism/?op=LoadContextBook` + ]); + }); + + it('should handle multiple intercept types', () => { + const viewConfig: BaseViewConfig = { + interceptUrls: [ + InterceptedApiType.AnswerData, + InterceptedApiType.LiveboardData + ] + }; + + const result = apiIntercept.getInterceptInitData(viewConfig); + + expect(result.interceptUrls).toContain(`${thoughtSpotHost}/prism/?op=GetChartWithData`); + expect(result.interceptUrls).toContain(`${thoughtSpotHost}/prism/?op=LoadContextBook`); + expect(result.interceptUrls.length).toBe(4); + }); + + it('should handle custom URL strings', () => { + const customUrl = '/api/custom-endpoint'; + const viewConfig: BaseViewConfig = { + interceptUrls: [customUrl] + }; + + const result = apiIntercept.getInterceptInitData(viewConfig); + + expect(result.interceptUrls).toEqual([`${thoughtSpotHost}${customUrl}`]); + }); + + it('should handle full URL strings', () => { + const fullUrl = 'https://example.com/api/endpoint'; + const viewConfig: BaseViewConfig = { + interceptUrls: [fullUrl] + }; + + const result = apiIntercept.getInterceptInitData(viewConfig); + + expect(result.interceptUrls).toEqual([fullUrl]); + }); + + it('should handle InterceptedApiType.ALL', () => { + const viewConfig: BaseViewConfig = { + interceptUrls: [InterceptedApiType.ALL] + }; + + const result = apiIntercept.getInterceptInitData(viewConfig); + + expect(result.interceptUrls).toEqual([InterceptedApiType.ALL]); + }); + + it('should prioritize ALL over other intercept types', () => { + const viewConfig: BaseViewConfig = { + interceptUrls: [ + InterceptedApiType.AnswerData, + InterceptedApiType.ALL, + '/api/custom' + ] + }; + + const result = apiIntercept.getInterceptInitData(viewConfig); + + expect(result.interceptUrls).toEqual([InterceptedApiType.ALL]); + }); + + it('should handle legacy isOnBeforeGetVizDataInterceptEnabled flag', () => { + const viewConfig: any = { + isOnBeforeGetVizDataInterceptEnabled: true + }; + + const result = apiIntercept.getInterceptInitData(viewConfig); + + expect(result.interceptUrls).toContain(`${thoughtSpotHost}/prism/?op=GetChartWithData`); + }); + + it('should combine legacy flag with interceptUrls', () => { + const viewConfig: any = { + isOnBeforeGetVizDataInterceptEnabled: true, + interceptUrls: [InterceptedApiType.LiveboardData] + }; + + const result = apiIntercept.getInterceptInitData(viewConfig); + + expect(result.interceptUrls).toContain(`${thoughtSpotHost}/prism/?op=GetChartWithData`); + expect(result.interceptUrls).toContain(`${thoughtSpotHost}/prism/?op=LoadContextBook`); + }); + + it('should pass through interceptTimeout', () => { + const viewConfig: BaseViewConfig = { + interceptUrls: [], + interceptTimeout: 5000 + }; + + const result = apiIntercept.getInterceptInitData(viewConfig); + + expect(result.interceptTimeout).toBe(5000); + }); + + it('should deduplicate URLs when same type is added multiple times', () => { + const viewConfig: BaseViewConfig = { + interceptUrls: [ + InterceptedApiType.AnswerData, + InterceptedApiType.AnswerData + ] + }; + + const result = apiIntercept.getInterceptInitData(viewConfig); + + expect(result.interceptUrls.length).toBe(3); // 3 answer data URLs + }); + }); + + describe('handleInterceptEvent', () => { + let executeEvent: jest.Mock; + let getUnsavedAnswerTml: jest.Mock; + let viewConfig: BaseViewConfig; + + beforeEach(() => { + executeEvent = jest.fn(); + getUnsavedAnswerTml = jest.fn().mockResolvedValue({ tml: 'test-tml' }); + viewConfig = {}; + }); + + it('should handle valid intercept data', async () => { + const eventData = { + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData', + init: { + method: 'POST', + body: JSON.stringify({ + variables: { + session: { sessionId: 'session-123' }, + contextBookId: 'viz-456' + } + }) + } + }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.ApiIntercept, + expect.objectContaining({ + input: '/prism/?op=GetChartWithData', + urlType: InterceptedApiType.AnswerData + }) + ); + }); + + it('should trigger legacy OnBeforeGetVizDataIntercept for answer data URLs', async () => { + viewConfig.isOnBeforeGetVizDataInterceptEnabled = true; + const eventData = { + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData', + init: { + method: 'POST', + body: JSON.stringify({ + variables: { + session: { sessionId: 'session-123' }, + contextBookId: 'viz-456' + } + }) + } + }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(getUnsavedAnswerTml).toHaveBeenCalledWith({ + sessionId: 'session-123', + vizId: 'viz-456' + }); + + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.OnBeforeGetVizDataIntercept, + { + data: { + data: { answer: { tml: 'test-tml' } }, + status: embedEventStatus.END, + type: EmbedEvent.OnBeforeGetVizDataIntercept + } + } + ); + + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.ApiIntercept, + expect.any(Object) + ); + }); + + it('should not trigger legacy intercept for non-answer data URLs', async () => { + viewConfig.isOnBeforeGetVizDataInterceptEnabled = true; + const eventData = { + data: JSON.stringify({ + input: '/prism/?op=LoadContextBook', + init: { + method: 'POST', + body: '{}' + } + }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(getUnsavedAnswerTml).not.toHaveBeenCalled(); + expect(executeEvent).toHaveBeenCalledTimes(1); + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.ApiIntercept, + expect.any(Object) + ); + }); + + it('should handle GetTableWithHeadlineData URL as answer data', async () => { + viewConfig.isOnBeforeGetVizDataInterceptEnabled = true; + const eventData = { + data: JSON.stringify({ + input: '/prism/?op=GetTableWithHeadlineData', + init: { + body: JSON.stringify({ + variables: { session: { sessionId: 'test' } } + }) + } + }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(getUnsavedAnswerTml).toHaveBeenCalled(); + }); + + it('should handle GetTableWithData URL as answer data', async () => { + viewConfig.isOnBeforeGetVizDataInterceptEnabled = true; + const eventData = { + data: JSON.stringify({ + input: '/prism/?op=GetTableWithData', + init: { + body: JSON.stringify({ + variables: { session: { sessionId: 'test' } } + }) + } + }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(getUnsavedAnswerTml).toHaveBeenCalled(); + }); + + it('should handle invalid JSON in event data', async () => { + const eventData = { + data: 'invalid-json' + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.Error, + { error: 'Error parsing api intercept body' } + ); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('should handle init with non-JSON body', async () => { + const eventData = { + data: JSON.stringify({ + input: '/api/test', + init: { + method: 'POST', + body: 'plain-text-body' + } + }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.ApiIntercept, + expect.objectContaining({ + init: expect.objectContaining({ + body: 'plain-text-body' + }) + }) + ); + }); + + it('should handle malformed event data structure with property access error', async () => { + // Create an object with a getter that throws when accessing 'input' + global.JSON.parse = jest.fn().mockImplementationOnce((str) => { + // Return an object with a getter that throws + return new Proxy({}, { + get(target, prop) { + if (prop === 'input') { + throw new Error('Property access error'); + } + return undefined; + } + }); + }); + + const eventData = { + data: JSON.stringify({ input: '/test', init: {} }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.Error, + { error: 'Error parsing api intercept body' } + ); + expect(mockLogger.error).toHaveBeenCalled(); + + // Explicitly restore for this test + global.JSON.parse = originalJsonParse; + }); + + it('should determine urlType as ALL for unknown URLs', async () => { + const eventData = { + data: JSON.stringify({ + input: '/unknown/endpoint', + init: { method: 'GET' } + }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.ApiIntercept, + expect.objectContaining({ + urlType: InterceptedApiType.ALL + }) + ); + }); + + it('should determine urlType as LiveboardData for liveboard URLs', async () => { + const eventData = { + data: JSON.stringify({ + input: '/prism/?op=LoadContextBook', + init: { method: 'POST' } + }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.ApiIntercept, + expect.objectContaining({ + urlType: InterceptedApiType.LiveboardData + }) + ); + }); + + it('should handle event data with missing init', async () => { + const eventData = { + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData' + }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + // When init is missing, accessing init.body throws an error + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.Error, + { error: 'Error parsing api intercept body' } + ); + }); + + it('should handle event data with missing body', async () => { + const eventData = { + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData', + init: {} + }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.ApiIntercept, + expect.any(Object) + ); + }); + + it('should handle event data with missing variables in body', async () => { + const eventData = { + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData', + init: { + body: JSON.stringify({}) + } + }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.ApiIntercept, + expect.any(Object) + ); + }); + + it('should handle event data with missing session in variables', async () => { + const eventData = { + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData', + init: { + body: JSON.stringify({ + variables: {} + }) + } + }) + }; + + await apiIntercept.handleInterceptEvent({ + eventData, + executeEvent, + viewConfig, + getUnsavedAnswerTml + }); + + expect(executeEvent).toHaveBeenCalledWith( + EmbedEvent.ApiIntercept, + expect.any(Object) + ); + }); + }); + + describe('processApiInterceptResponse', () => { + it('should process legacy format with error', () => { + const legacyPayload = { + data: { + error: { + errorText: 'Test Error', + errorDescription: 'Test Description' + }, + execute: false + } + }; + + const result = apiIntercept.processApiInterceptResponse(legacyPayload); + + expect(result).toEqual({ + data: { + execute: false, + response: { + body: { + errors: [ + { + title: 'Test Error', + description: 'Test Description', + isUserError: true, + }, + ], + data: {}, + }, + }, + } + }); + }); + + it('should pass through new format unchanged', () => { + const newPayload = { + execute: true, + response: { + body: { data: 'test' } + } + }; + + const result = apiIntercept.processApiInterceptResponse(newPayload); + + expect(result).toEqual(newPayload); + }); + + it('should handle payload without data property', () => { + const payload = { + execute: true + }; + + const result = apiIntercept.processApiInterceptResponse(payload); + + expect(result).toEqual(payload); + }); + + it('should handle payload with data but no error', () => { + const payload = { + data: { + execute: true, + someOtherProperty: 'value' + } + }; + + const result = apiIntercept.processApiInterceptResponse(payload); + + expect(result).toEqual(payload); + }); + + it('should handle null payload', () => { + const result = apiIntercept.processApiInterceptResponse(null); + + expect(result).toBeNull(); + }); + + it('should handle undefined payload', () => { + const result = apiIntercept.processApiInterceptResponse(undefined); + + expect(result).toBeUndefined(); + }); + + it('should handle payload with null data', () => { + const payload: any = { + data: null + }; + + const result = apiIntercept.processApiInterceptResponse(payload); + + expect(result).toEqual(payload); + }); + + it('should handle payload with data.error set to null', () => { + const payload: any = { + data: { + error: null, + execute: true + } + }; + + const result = apiIntercept.processApiInterceptResponse(payload); + + expect(result).toEqual(payload); + }); + + it('should handle payload with data.error set to undefined', () => { + const payload: any = { + data: { + error: undefined, + execute: true + } + }; + + const result = apiIntercept.processApiInterceptResponse(payload); + + expect(result).toEqual(payload); + }); + + it('should handle payload with data.error set to false', () => { + const payload = { + data: { + error: false, + execute: true + } + }; + + const result = apiIntercept.processApiInterceptResponse(payload); + + expect(result).toEqual(payload); + }); + + it('should handle payload with data.error set to 0', () => { + const payload = { + data: { + error: 0, + execute: true + } + }; + + const result = apiIntercept.processApiInterceptResponse(payload); + + expect(result).toEqual(payload); + }); + + it('should handle payload with data.error set to empty string', () => { + const payload = { + data: { + error: '', + execute: true + } + }; + + const result = apiIntercept.processApiInterceptResponse(payload); + + expect(result).toEqual(payload); + }); + }); + + describe('processLegacyInterceptResponse', () => { + it('should convert legacy error format to new format', () => { + const legacyPayload = { + data: { + error: { + errorText: 'Custom Error', + errorDescription: 'Custom Description' + }, + execute: false + } + }; + + const result = apiIntercept.processLegacyInterceptResponse(legacyPayload); + + expect(result).toEqual({ + data: { + execute: false, + response: { + body: { + errors: [ + { + title: 'Custom Error', + description: 'Custom Description', + isUserError: true, + }, + ], + data: {}, + }, + }, + } + }); + }); + + it('should handle missing error properties', () => { + const legacyPayload = { + data: { + error: {}, + execute: true + } + }; + + const result = apiIntercept.processLegacyInterceptResponse(legacyPayload); + + expect(result.data.response.body.errors[0]).toEqual({ + title: undefined, + description: undefined, + isUserError: true, + }); + }); + + it('should handle missing execute property', () => { + const legacyPayload = { + data: { + error: { + errorText: 'Error', + errorDescription: 'Description' + } + } + }; + + const result = apiIntercept.processLegacyInterceptResponse(legacyPayload); + + expect(result.data.execute).toBeUndefined(); + }); + + it('should always include empty data object', () => { + const legacyPayload = { + data: { + error: { + errorText: 'Error', + errorDescription: 'Description' + }, + execute: false + } + }; + + const result = apiIntercept.processLegacyInterceptResponse(legacyPayload); + + expect(result.data.response.body.data).toEqual({}); + }); + + it('should always set isUserError to true', () => { + const legacyPayload = { + data: { + error: { + errorText: 'Error', + errorDescription: 'Description' + }, + execute: false + } + }; + + const result = apiIntercept.processLegacyInterceptResponse(legacyPayload); + + expect(result.data.response.body.errors[0].isUserError).toBe(true); + }); + + it('should handle payload with null data', () => { + const legacyPayload: any = { + data: null + }; + + const result = apiIntercept.processLegacyInterceptResponse(legacyPayload); + + expect(result.data.execute).toBeUndefined(); + expect(result.data.response.body.errors[0].title).toBeUndefined(); + expect(result.data.response.body.errors[0].description).toBeUndefined(); + }); + + it('should handle payload with null error', () => { + const legacyPayload: any = { + data: { + error: null, + execute: true + } + }; + + const result = apiIntercept.processLegacyInterceptResponse(legacyPayload); + + expect(result.data.execute).toBe(true); + expect(result.data.response.body.errors[0].title).toBeUndefined(); + expect(result.data.response.body.errors[0].description).toBeUndefined(); + }); + + it('should handle payload with undefined properties', () => { + const legacyPayload = {}; + + const result = apiIntercept.processLegacyInterceptResponse(legacyPayload); + + expect(result.data.execute).toBeUndefined(); + expect(result.data.response.body.errors[0].title).toBeUndefined(); + expect(result.data.response.body.errors[0].description).toBeUndefined(); + }); + }); +}); + diff --git a/src/api-intercept.ts b/src/api-intercept.ts index 4621d83e..793b2e34 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -51,7 +51,7 @@ const processInterceptUrls = (interceptUrls: (string | InterceptedApiType)[]) => * @param viewConfig * @returns */ -export const getInterceptInitData = (viewConfig: BaseViewConfig): ApiInterceptFlags => { +export const getInterceptInitData = (viewConfig: BaseViewConfig): Required> => { const combinedUrls = [...(viewConfig.interceptUrls || [])]; if ((viewConfig as LegacyInterceptFlags).isOnBeforeGetVizDataInterceptEnabled) { diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 662c0e70..7ed4d293 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -28,6 +28,7 @@ import { ContextMenuTriggerOptions, CustomActionTarget, CustomActionsPosition, + DefaultAppInitData, } from '../types'; import { executeAfterWait, @@ -110,6 +111,31 @@ const customVariablesForThirdPartyTools = { key2: '*%^', }; +const getMockAppInitPayload = (data: any) => { + const defaultData: DefaultAppInitData = { + customisations, + authToken: '', + hostConfig: undefined, + runtimeFilterParams: null, + runtimeParameterParams: null, + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [HomepageModule.MyLibrary, HomepageModule.Learning], + hiddenListColumns: [], + customActions: [], + reorderedHomepageModules: [], + customVariablesForThirdPartyTools, + interceptTimeout: undefined, + interceptUrls: [], + }; + return { + type: EmbedEvent.APP_INIT, + data: { + ...defaultData, + ...data, + }, + }; +} + describe('Unit test case for ts embed', () => { const mockMixPanelEvent = jest.spyOn(mixpanelInstance, 'uploadMixpanelEvent'); beforeEach(() => { @@ -337,22 +363,19 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations, - authToken: '', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - }, - }); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations, + authToken: '', + runtimeFilterParams: null, + runtimeParameterParams: null, + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [], + hiddenListColumns: [], + customActions: [], + hostConfig: undefined, + reorderedHomepageModules: [], + customVariablesForThirdPartyTools, + })); }); }); @@ -374,22 +397,19 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations: customisationsView, - authToken: '', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - }, - }); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations: customisationsView, + authToken: '', + runtimeFilterParams: null, + runtimeParameterParams: null, + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [], + hiddenListColumns: [], + customActions: [], + hostConfig: undefined, + reorderedHomepageModules: [], + customVariablesForThirdPartyTools, + })); }); }); @@ -416,22 +436,19 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations, - authToken: '', - hostConfig: undefined, - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [HomepageModule.MyLibrary, HomepageModule.Learning], - hiddenListColumns: [], - customActions: [], - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - }, - }); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations, + authToken: '', + hostConfig: undefined, + runtimeFilterParams: null, + runtimeParameterParams: null, + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [HomepageModule.MyLibrary, HomepageModule.Learning], + hiddenListColumns: [], + customActions: [], + reorderedHomepageModules: [], + customVariablesForThirdPartyTools, + })); }); }); @@ -455,22 +472,19 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations, - authToken: '', - hostConfig: undefined, - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - }, - }); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations, + authToken: '', + hostConfig: undefined, + runtimeFilterParams: null, + runtimeParameterParams: null, + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [], + hiddenListColumns: [], + customActions: [], + reorderedHomepageModules: [], + customVariablesForThirdPartyTools, + })); }); }); @@ -497,23 +511,20 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations, - authToken: '', - hostConfig: undefined, - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - reorderedHomepageModules: - [HomepageModule.MyLibrary, HomepageModule.Watchlist], - customVariablesForThirdPartyTools, - }, - }); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations, + authToken: '', + hostConfig: undefined, + runtimeFilterParams: null, + runtimeParameterParams: null, + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [], + hiddenListColumns: [], + customActions: [], + reorderedHomepageModules: + [HomepageModule.MyLibrary, HomepageModule.Watchlist], + customVariablesForThirdPartyTools, + })); }); }); @@ -543,22 +554,19 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations, - authToken: '', - runtimeFilterParams: null, - runtimeParameterParams: 'param1=color¶mVal1=blue', - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - }, - }); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations, + authToken: '', + runtimeFilterParams: null, + runtimeParameterParams: 'param1=color¶mVal1=blue', + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [], + hiddenListColumns: [], + customActions: [], + hostConfig: undefined, + reorderedHomepageModules: [], + customVariablesForThirdPartyTools, + })); }); }); @@ -589,22 +597,19 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations, - authToken: '', - runtimeFilterParams: 'col1=color&op1=EQ&val1=blue', - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - }, - }); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations, + authToken: '', + runtimeFilterParams: 'col1=color&op1=EQ&val1=blue', + runtimeParameterParams: null, + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [], + hiddenListColumns: [], + customActions: [], + hostConfig: undefined, + reorderedHomepageModules: [], + customVariablesForThirdPartyTools, + })); }); }); @@ -634,22 +639,19 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations, - authToken: '', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - }, - }); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations, + authToken: '', + runtimeFilterParams: null, + runtimeParameterParams: null, + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [], + hiddenListColumns: [], + customActions: [], + hostConfig: undefined, + reorderedHomepageModules: [], + customVariablesForThirdPartyTools, + })); }); }); @@ -680,22 +682,19 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations, - authToken: '', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - }, - }); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations, + authToken: '', + runtimeFilterParams: null, + runtimeParameterParams: null, + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [], + hiddenListColumns: [], + customActions: [], + hostConfig: undefined, + reorderedHomepageModules: [], + customVariablesForThirdPartyTools, + })); }); }); @@ -723,23 +722,20 @@ describe('Unit test case for ts embed', () => { }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations, - authToken: '', - hostConfig: undefined, - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: - [HomeLeftNavItem.Home, HomeLeftNavItem.MonitorSubscription], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - }, - }); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations, + authToken: '', + hostConfig: undefined, + runtimeFilterParams: null, + runtimeParameterParams: null, + hiddenHomeLeftNavItems: + [HomeLeftNavItem.Home, HomeLeftNavItem.MonitorSubscription], + hiddenHomepageModules: [], + hiddenListColumns: [], + customActions: [], + reorderedHomepageModules: [], + customVariablesForThirdPartyTools, + })); }); }); @@ -894,22 +890,19 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations, - authToken: 'test_auth_token1', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools: {}, - }, - }); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations, + authToken: 'test_auth_token1', + runtimeFilterParams: null, + runtimeParameterParams: null, + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [], + hiddenListColumns: [], + customActions: [], + hostConfig: undefined, + reorderedHomepageModules: [], + customVariablesForThirdPartyTools: {}, + })); }); jest.spyOn(authService, 'verifyTokenService').mockClear(); @@ -965,36 +958,33 @@ describe('Unit test case for ts embed', () => { }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations: { - content: { - strings: { - Liveboard: 'Dashboard', - }, - stringIDsUrl: 'https://sample-string-ids-url.com', - stringIDs: { - 'liveboard.header.title': 'Dashboard name', - }, + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations: { + content: { + strings: { + Liveboard: 'Dashboard', }, - style: { - customCSS: {}, - customCSSUrl: undefined, + stringIDsUrl: 'https://sample-string-ids-url.com', + stringIDs: { + 'liveboard.header.title': 'Dashboard name', }, }, - authToken: 'test_auth_token1', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools: {}, + style: { + customCSS: {}, + customCSSUrl: undefined, + }, }, - }); + authToken: 'test_auth_token1', + runtimeFilterParams: null, + runtimeParameterParams: null, + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [], + hiddenListColumns: [], + customActions: [], + hostConfig: undefined, + reorderedHomepageModules: [], + customVariablesForThirdPartyTools: {}, + })); const customisationContent = mockPort.postMessage.mock.calls[0][0].data.customisations.content; expect(customisationContent.stringIDsUrl) .toBe('https://sample-string-ids-url.com'); @@ -1079,43 +1069,40 @@ describe('Unit test case for ts embed', () => { }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith({ - type: EmbedEvent.APP_INIT, - data: { - customisations: { - content: {}, - style: { - customCSS: {}, - customCSSUrl: undefined, - }, + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ + customisations: { + content: {}, + style: { + customCSS: {}, + customCSSUrl: undefined, }, - authToken: 'test_auth_token1', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [ - { - id: 'action2', - name: 'Another Valid Action', - target: CustomActionTarget.VIZ, - position: CustomActionsPosition.MENU, - metadataIds: { vizIds: ['viz456'] } - }, - { - id: 'action1', - name: 'Valid Action', - target: CustomActionTarget.LIVEBOARD, - position: CustomActionsPosition.PRIMARY, - metadataIds: { liveboardIds: ['lb123'] } - } - ], // Actions should be sorted by name - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools: {}, }, - }); + authToken: 'test_auth_token1', + runtimeFilterParams: null, + runtimeParameterParams: null, + hiddenHomeLeftNavItems: [], + hiddenHomepageModules: [], + hiddenListColumns: [], + customActions: [ + { + id: 'action2', + name: 'Another Valid Action', + target: CustomActionTarget.VIZ, + position: CustomActionsPosition.MENU, + metadataIds: { vizIds: ['viz456'] } + }, + { + id: 'action1', + name: 'Valid Action', + target: CustomActionTarget.LIVEBOARD, + position: CustomActionsPosition.PRIMARY, + metadataIds: { liveboardIds: ['lb123'] } + } + ], // Actions should be sorted by name + hostConfig: undefined, + reorderedHomepageModules: [], + customVariablesForThirdPartyTools: {}, + })); // Verify that CustomActionsValidationResult structure is // correct @@ -3510,9 +3497,9 @@ describe('Unit test case for ts embed', () => { appEmbed.destroy(); - // Should be called immediately when waitForCleanupOnDestroy is true + // Should be called immediately when config is enabled expect(triggerSpy).toHaveBeenCalledWith(HostEvent.DestroyEmbed); - + // Wait for the timeout to complete await new Promise(resolve => setTimeout(resolve, 1100)); @@ -3535,7 +3522,7 @@ describe('Unit test case for ts embed', () => { await appEmbed.render(); // Mock trigger to resolve quickly (before timeout) - const triggerSpy = jest.spyOn(appEmbed, 'trigger').mockImplementation(() => + const triggerSpy = jest.spyOn(appEmbed, 'trigger').mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(null), 100)) ); const removeChildSpy = jest.spyOn(Node.prototype, 'removeChild').mockImplementation(() => getRootEl()); @@ -3565,7 +3552,7 @@ describe('Unit test case for ts embed', () => { await appEmbed.render(); // Mock trigger to take longer than timeout - const triggerSpy = jest.spyOn(appEmbed, 'trigger').mockImplementation(() => + const triggerSpy = jest.spyOn(appEmbed, 'trigger').mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(null), 500)) ); const removeChildSpy = jest.spyOn(Node.prototype, 'removeChild').mockImplementation(() => getRootEl()); diff --git a/src/types.ts b/src/types.ts index bbbba52d..b8323a8f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6045,6 +6045,8 @@ export interface DefaultAppInitData { customVariablesForThirdPartyTools: Record; hiddenListColumns: ListPageColumns[]; customActions: CustomAction[]; + interceptTimeout: number | undefined; + interceptUrls: (string | InterceptedApiType)[]; } /** From 09ffad33f5a59d3c9c90981c021b5eb76b0c81dd Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 19:02:48 +0530 Subject: [PATCH 17/28] SCAL-269016 : nit --- eslint.config.mjs | 2 +- src/api-intercept.ts | 9 ++------- src/embed/ts-embed.ts | 4 ++-- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 49294f6b..879d8e50 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -51,4 +51,4 @@ export default defineConfig([ 'import/no-cycle': "error" }, }, -]); \ No newline at end of file +]); diff --git a/src/api-intercept.ts b/src/api-intercept.ts index 793b2e34..8e3810bb 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -21,11 +21,6 @@ const formatInterceptUrl = (url: string) => { return url; } -export const processApiIntercept = async (eventData: any) => { - - return JSON.parse(eventData.data); -} - interface LegacyInterceptFlags { isOnBeforeGetVizDataInterceptEnabled: boolean; } @@ -79,8 +74,8 @@ const parseJson = (jsonString: string): [any, Error | null] => { } /** - * - * @param fetchInit + * Parse the api intercept data and return the parsed data and error if any + * Embed returns the input and init from the fetch call */ const parseInterceptData = (eventDataString: any) => { diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index e00f47b4..8a30b777 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -348,12 +348,12 @@ export class TsEmbed { handleInterceptEvent({ eventData, executeEvent, viewConfig: this.viewConfig, getUnsavedAnswerTml }); } - private messageEventListener = async (event: MessageEvent) => { + private messageEventListener = (event: MessageEvent) => { const eventType = this.getEventType(event); const eventPort = this.getEventPort(event); const eventData = this.formatEventData(event, eventType); if (event.source === this.iFrame.contentWindow) { - const processedEventData = await processEventData( + const processedEventData = processEventData( eventType, eventData, this.thoughtSpotHost, From 3fcc123708fa671c61d7e60c8bed64675a6943d7 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 19:06:15 +0530 Subject: [PATCH 18/28] SCAL-269016 : fix test --- src/api-intercept.spec.ts | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/src/api-intercept.spec.ts b/src/api-intercept.spec.ts index a66b4672..917ee606 100644 --- a/src/api-intercept.spec.ts +++ b/src/api-intercept.spec.ts @@ -34,36 +34,6 @@ describe('api-intercept', () => { JSON.parse = originalJsonParse; }); - describe('processApiIntercept', () => { - it('should parse and return JSON data', async () => { - const eventData = { - data: JSON.stringify({ key: 'value' }) - }; - - const result = await apiIntercept.processApiIntercept(eventData); - - expect(result).toEqual({ key: 'value' }); - }); - - it('should handle complex nested objects', async () => { - const complexData = { - nested: { - deep: { - value: 'test', - array: [1, 2, 3] - } - } - }; - const eventData = { - data: JSON.stringify(complexData) - }; - - const result = await apiIntercept.processApiIntercept(eventData); - - expect(result).toEqual(complexData); - }); - }); - describe('getInterceptInitData', () => { it('should return default intercept flags when no intercepts are configured', () => { const viewConfig: BaseViewConfig = {}; From def1b446e3c4b1c1290b299722504ecc1342efa5 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 19:23:33 +0530 Subject: [PATCH 19/28] SCAL-269016 : more test --- src/embed/ts-embed.spec.ts | 388 +++++++++++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 7ed4d293..0a8fb620 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -61,10 +61,13 @@ import { processTrigger } from '../utils/processTrigger'; import { UIPassthroughEvent } from './hostEventClient/contracts'; import * as sessionInfoService from '../utils/sessionInfoService'; import * as authToken from '../authToken'; +import * as apiIntercept from '../api-intercept'; jest.mock('../utils/processTrigger'); +jest.mock('../api-intercept'); const mockProcessTrigger = processTrigger as jest.Mock; +const mockHandleInterceptEvent = apiIntercept.handleInterceptEvent as jest.Mock; const defaultViewConfig = { frameParams: { width: 1280, @@ -3567,4 +3570,389 @@ describe('Unit test case for ts embed', () => { }); }); }); + + describe('handleApiInterceptEvent', () => { + beforeEach(() => { + document.body.innerHTML = getDocumentBody(); + init({ + thoughtSpotHost: 'tshost', + authType: AuthType.None, + }); + jest.clearAllMocks(); + mockHandleInterceptEvent.mockClear(); + }); + + test('should call handleInterceptEvent with correct parameters', async () => { + const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); + await searchEmbed.render(); + + const mockEventData = { + type: EmbedEvent.ApiIntercept, + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData', + init: { + method: 'POST', + body: JSON.stringify({ + variables: { + session: { sessionId: 'session-123' }, + contextBookId: 'viz-456' + } + }) + } + }) + }; + + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEventData, mockPort); + }); + + await executeAfterWait(() => { + expect(mockHandleInterceptEvent).toHaveBeenCalledTimes(1); + expect(mockHandleInterceptEvent).toHaveBeenCalledWith({ + eventData: mockEventData, + executeEvent: expect.any(Function), + viewConfig: defaultViewConfig, + getUnsavedAnswerTml: expect.any(Function), + }); + }); + }); + + test('should execute callbacks through executeEvent function', async () => { + let capturedExecuteEvent: any; + mockHandleInterceptEvent.mockImplementation((params) => { + capturedExecuteEvent = params.executeEvent; + }); + + const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); + const mockCallback = jest.fn(); + searchEmbed.on(EmbedEvent.CustomAction, mockCallback); + await searchEmbed.render(); + + const mockEventData = { + type: EmbedEvent.ApiIntercept, + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData', + init: {} + }) + }; + + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEventData, mockPort); + }); + + await executeAfterWait(() => { + expect(capturedExecuteEvent).toBeDefined(); + + // Simulate executeEvent being called by handleInterceptEvent + const testData = { test: 'data' }; + capturedExecuteEvent(EmbedEvent.CustomAction, testData); + + // executeEvent passes data as first param to callback + expect(mockCallback).toHaveBeenCalled(); + expect(mockCallback.mock.calls[0][0]).toEqual(testData); + }); + }); + + test('should call triggerUIPassThrough through getUnsavedAnswerTml function', async () => { + let capturedGetUnsavedAnswerTml: any; + mockHandleInterceptEvent.mockImplementation((params) => { + capturedGetUnsavedAnswerTml = params.getUnsavedAnswerTml; + }); + + const mockTmlResponse = { tml: 'test-tml-content' }; + mockProcessTrigger.mockResolvedValue([{ value: mockTmlResponse }]); + + const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); + await searchEmbed.render(); + + const mockEventData = { + type: EmbedEvent.ApiIntercept, + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData', + init: {} + }) + }; + + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEventData, mockPort); + }); + + await executeAfterWait(async () => { + expect(capturedGetUnsavedAnswerTml).toBeDefined(); + + // Simulate getUnsavedAnswerTml being called by + // handleInterceptEvent + const result = await capturedGetUnsavedAnswerTml({ + sessionId: 'session-123', + vizId: 'viz-456' + }); + + expect(mockProcessTrigger).toHaveBeenCalled(); + const callArgs = mockProcessTrigger.mock.calls[0]; + expect(callArgs[1]).toBe(UIPassthroughEvent.GetUnsavedAnswerTML); + expect(callArgs[3]).toEqual({ + sessionId: 'session-123', + vizId: 'viz-456' + }); + expect(result).toEqual(mockTmlResponse); + }); + }); + + test('should pass viewConfig to handleInterceptEvent', async () => { + const customViewConfig = { + ...defaultViewConfig, + interceptUrls: ['/api/test'], + interceptTimeout: 5000, + }; + + const searchEmbed = new SearchEmbed(getRootEl(), customViewConfig); + await searchEmbed.render(); + + const mockEventData = { + type: EmbedEvent.ApiIntercept, + data: JSON.stringify({ + input: '/api/test', + init: {} + }) + }; + + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEventData, mockPort); + }); + + await executeAfterWait(() => { + const call = mockHandleInterceptEvent.mock.calls[0][0]; + expect(call.viewConfig).toMatchObject({ + interceptUrls: ['/api/test'], + interceptTimeout: 5000, + }); + }); + }); + + test('should handle ApiIntercept event with eventPort', async () => { + const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); + await searchEmbed.render(); + + const mockEventData = { + type: EmbedEvent.ApiIntercept, + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData', + init: {} + }) + }; + + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEventData, mockPort); + }); + + await executeAfterWait(() => { + expect(mockHandleInterceptEvent).toHaveBeenCalled(); + + // Verify the executeEvent function uses the port + const executeEventFn = mockHandleInterceptEvent.mock.calls[0][0].executeEvent; + expect(executeEventFn).toBeDefined(); + }); + }); + + test('should not process non-ApiIntercept events through handleApiInterceptEvent', async () => { + const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); + await searchEmbed.render(); + + const mockEventData = { + type: EmbedEvent.Save, + data: { answerId: '123' }, + }; + + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEventData, mockPort); + }); + + await executeAfterWait(() => { + expect(mockHandleInterceptEvent).not.toHaveBeenCalled(); + }); + }); + + test('should handle multiple ApiIntercept events', async () => { + const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); + await searchEmbed.render(); + + const mockEventData1 = { + type: EmbedEvent.ApiIntercept, + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData', + init: {} + }) + }; + + const mockEventData2 = { + type: EmbedEvent.ApiIntercept, + data: JSON.stringify({ + input: '/prism/?op=LoadContextBook', + init: {} + }) + }; + + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEventData1, mockPort); + }); + + await executeAfterWait(() => { + postMessageToParent(getIFrameEl().contentWindow, mockEventData2, mockPort); + }); + + await executeAfterWait(() => { + expect(mockHandleInterceptEvent).toHaveBeenCalledTimes(2); + }); + }); + + test('should pass eventPort to executeCallbacks', async () => { + let capturedExecuteEvent: any; + mockHandleInterceptEvent.mockImplementation((params) => { + capturedExecuteEvent = params.executeEvent; + }); + + const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); + const mockCallback = jest.fn(); + searchEmbed.on(EmbedEvent.ApiIntercept, mockCallback); + await searchEmbed.render(); + + const mockEventData = { + type: EmbedEvent.ApiIntercept, + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData', + init: {} + }) + }; + + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEventData, mockPort); + }); + + await executeAfterWait(() => { + expect(capturedExecuteEvent).toBeDefined(); + + // Call executeEvent with a response + const responseData = { execute: true }; + capturedExecuteEvent(EmbedEvent.ApiIntercept, responseData); + + // Verify the callback was invoked with the data + expect(mockCallback).toHaveBeenCalled(); + expect(mockCallback.mock.calls[0][0]).toEqual(responseData); + }); + }); + + test('should handle getUnsavedAnswerTml with empty response', async () => { + let capturedGetUnsavedAnswerTml: any; + mockHandleInterceptEvent.mockImplementation((params) => { + capturedGetUnsavedAnswerTml = params.getUnsavedAnswerTml; + }); + + mockProcessTrigger.mockResolvedValue([]); + + const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); + await searchEmbed.render(); + + const mockEventData = { + type: EmbedEvent.ApiIntercept, + data: JSON.stringify({ + input: '/prism/?op=GetChartWithData', + init: {} + }) + }; + + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEventData, mockPort); + }); + + await executeAfterWait(async () => { + expect(capturedGetUnsavedAnswerTml).toBeDefined(); + + const result = await capturedGetUnsavedAnswerTml({ + sessionId: 'session-123', + vizId: 'viz-456' + }); + + expect(result).toBeUndefined(); + }); + }); + + test('should work with LiveboardEmbed', async () => { + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId: 'test-liveboard-id', + }); + await liveboardEmbed.render(); + + const mockEventData = { + type: EmbedEvent.ApiIntercept, + data: JSON.stringify({ + input: '/prism/?op=LoadContextBook', + init: {} + }) + }; + + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEventData, mockPort); + }); + + await executeAfterWait(() => { + expect(mockHandleInterceptEvent).toHaveBeenCalledTimes(1); + expect(mockHandleInterceptEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventData: mockEventData, + }) + ); + }); + }); + }); }); From 299b7dab3f5d6581e4bf7e65990acffa1dbe9870 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 19:24:39 +0530 Subject: [PATCH 20/28] SCAL-269016 : test fix --- src/embed/ts-embed.spec.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 0a8fb620..cba43675 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -3613,12 +3613,11 @@ describe('Unit test case for ts embed', () => { await executeAfterWait(() => { expect(mockHandleInterceptEvent).toHaveBeenCalledTimes(1); - expect(mockHandleInterceptEvent).toHaveBeenCalledWith({ - eventData: mockEventData, - executeEvent: expect.any(Function), - viewConfig: defaultViewConfig, - getUnsavedAnswerTml: expect.any(Function), - }); + const call = mockHandleInterceptEvent.mock.calls[0][0]; + expect(call.eventData).toEqual(mockEventData); + expect(call.executeEvent).toBeInstanceOf(Function); + expect(call.getUnsavedAnswerTml).toBeInstanceOf(Function); + expect(call.viewConfig).toMatchObject(defaultViewConfig); }); }); @@ -3694,14 +3693,17 @@ describe('Unit test case for ts embed', () => { await executeAfterWait(async () => { expect(capturedGetUnsavedAnswerTml).toBeDefined(); - + + // Clear previous calls + mockProcessTrigger.mockClear(); + // Simulate getUnsavedAnswerTml being called by // handleInterceptEvent const result = await capturedGetUnsavedAnswerTml({ sessionId: 'session-123', vizId: 'viz-456' }); - + expect(mockProcessTrigger).toHaveBeenCalled(); const callArgs = mockProcessTrigger.mock.calls[0]; expect(callArgs[1]).toBe(UIPassthroughEvent.GetUnsavedAnswerTML); From 9b35735c747018fa5ab5d309fbb523b0cdda9855 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 19:26:31 +0530 Subject: [PATCH 21/28] SCAL-269016 : fix --- src/embed/ts-embed.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index cba43675..70be0387 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -3706,10 +3706,14 @@ describe('Unit test case for ts embed', () => { expect(mockProcessTrigger).toHaveBeenCalled(); const callArgs = mockProcessTrigger.mock.calls[0]; - expect(callArgs[1]).toBe(UIPassthroughEvent.GetUnsavedAnswerTML); - expect(callArgs[3]).toEqual({ - sessionId: 'session-123', - vizId: 'viz-456' + // Verify UIPassthrough event is triggered with the right params + expect(callArgs[1]).toBe('UiPassthrough'); + expect(callArgs[3]).toMatchObject({ + type: 'getUnsavedAnswerTML', + parameters: { + sessionId: 'session-123', + vizId: 'viz-456' + } }); expect(result).toEqual(mockTmlResponse); }); From bd95575f2acf8f877b08cfe09c2a95b38c6aa805 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 19:32:05 +0530 Subject: [PATCH 22/28] SCAL-269016 : gues what MORE TEST --- src/embed/ts-embed.spec.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 70be0387..4095c294 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -64,10 +64,9 @@ import * as authToken from '../authToken'; import * as apiIntercept from '../api-intercept'; jest.mock('../utils/processTrigger'); -jest.mock('../api-intercept'); const mockProcessTrigger = processTrigger as jest.Mock; -const mockHandleInterceptEvent = apiIntercept.handleInterceptEvent as jest.Mock; +const mockHandleInterceptEvent = jest.spyOn(apiIntercept, 'handleInterceptEvent'); const defaultViewConfig = { frameParams: { width: 1280, @@ -3693,17 +3692,17 @@ describe('Unit test case for ts embed', () => { await executeAfterWait(async () => { expect(capturedGetUnsavedAnswerTml).toBeDefined(); - + // Clear previous calls mockProcessTrigger.mockClear(); - + // Simulate getUnsavedAnswerTml being called by // handleInterceptEvent const result = await capturedGetUnsavedAnswerTml({ sessionId: 'session-123', vizId: 'viz-456' }); - + expect(mockProcessTrigger).toHaveBeenCalled(); const callArgs = mockProcessTrigger.mock.calls[0]; // Verify UIPassthrough event is triggered with the right params From c7fa693cb2f51f123d55d4247420c96c41bfde1e Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 19:39:44 +0530 Subject: [PATCH 23/28] SCAL-269016 : gues what MORE TEST again --- src/embed/ts-embed.spec.ts | 142 ++----------------------------------- 1 file changed, 5 insertions(+), 137 deletions(-) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 4095c294..f676c566 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -121,7 +121,7 @@ const getMockAppInitPayload = (data: any) => { runtimeFilterParams: null, runtimeParameterParams: null, hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [HomepageModule.MyLibrary, HomepageModule.Learning], + hiddenHomepageModules: [], hiddenListColumns: [], customActions: [], reorderedHomepageModules: [], @@ -365,19 +365,7 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ - customisations, - authToken: '', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - })); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({})); }); }); @@ -401,16 +389,6 @@ describe('Unit test case for ts embed', () => { await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ customisations: customisationsView, - authToken: '', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, })); }); }); @@ -439,17 +417,7 @@ describe('Unit test case for ts embed', () => { }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ - customisations, - authToken: '', - hostConfig: undefined, - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], hiddenHomepageModules: [HomepageModule.MyLibrary, HomepageModule.Learning], - hiddenListColumns: [], - customActions: [], - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, })); }); }); @@ -474,19 +442,7 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ - customisations, - authToken: '', - hostConfig: undefined, - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - })); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({})); }); }); @@ -514,18 +470,8 @@ describe('Unit test case for ts embed', () => { }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ - customisations, - authToken: '', - hostConfig: undefined, - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], reorderedHomepageModules: [HomepageModule.MyLibrary, HomepageModule.Watchlist], - customVariablesForThirdPartyTools, })); }); }); @@ -557,17 +503,7 @@ describe('Unit test case for ts embed', () => { }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ - customisations, - authToken: '', - runtimeFilterParams: null, runtimeParameterParams: 'param1=color¶mVal1=blue', - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, })); }); }); @@ -600,17 +536,7 @@ describe('Unit test case for ts embed', () => { }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ - customisations, - authToken: '', runtimeFilterParams: 'col1=color&op1=EQ&val1=blue', - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, })); }); }); @@ -641,19 +567,7 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ - customisations, - authToken: '', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - })); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({})); }); }); @@ -684,19 +598,7 @@ describe('Unit test case for ts embed', () => { postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { - expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ - customisations, - authToken: '', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, - })); + expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({})); }); }); @@ -725,18 +627,8 @@ describe('Unit test case for ts embed', () => { await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ - customisations, - authToken: '', - hostConfig: undefined, - runtimeFilterParams: null, - runtimeParameterParams: null, hiddenHomeLeftNavItems: [HomeLeftNavItem.Home, HomeLeftNavItem.MonitorSubscription], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - reorderedHomepageModules: [], - customVariablesForThirdPartyTools, })); }); }); @@ -893,16 +785,7 @@ describe('Unit test case for ts embed', () => { }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({ - customisations, authToken: 'test_auth_token1', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], customVariablesForThirdPartyTools: {}, })); }); @@ -977,14 +860,6 @@ describe('Unit test case for ts embed', () => { }, }, authToken: 'test_auth_token1', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], - customActions: [], - hostConfig: undefined, - reorderedHomepageModules: [], customVariablesForThirdPartyTools: {}, })); const customisationContent = mockPort.postMessage.mock.calls[0][0].data.customisations.content; @@ -1080,11 +955,6 @@ describe('Unit test case for ts embed', () => { }, }, authToken: 'test_auth_token1', - runtimeFilterParams: null, - runtimeParameterParams: null, - hiddenHomeLeftNavItems: [], - hiddenHomepageModules: [], - hiddenListColumns: [], customActions: [ { id: 'action2', @@ -1101,8 +971,6 @@ describe('Unit test case for ts embed', () => { metadataIds: { liveboardIds: ['lb123'] } } ], // Actions should be sorted by name - hostConfig: undefined, - reorderedHomepageModules: [], customVariablesForThirdPartyTools: {}, })); From 561f4a288444ec6bf53e1b04f4946ced79a4bf63 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 19:54:17 +0530 Subject: [PATCH 24/28] SCAL-269016 : test --- src/api-intercept.spec.ts | 2 +- src/api-intercept.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api-intercept.spec.ts b/src/api-intercept.spec.ts index 917ee606..84575377 100644 --- a/src/api-intercept.spec.ts +++ b/src/api-intercept.spec.ts @@ -187,7 +187,7 @@ describe('api-intercept', () => { beforeEach(() => { executeEvent = jest.fn(); - getUnsavedAnswerTml = jest.fn().mockResolvedValue({ tml: 'test-tml' }); + getUnsavedAnswerTml = jest.fn().mockResolvedValue({ answer: { tml: 'test-tml' } }); viewConfig = {}; }); diff --git a/src/api-intercept.ts b/src/api-intercept.ts index 8e3810bb..824594d8 100644 --- a/src/api-intercept.ts +++ b/src/api-intercept.ts @@ -151,7 +151,7 @@ export const handleInterceptEvent = async (params: { // Build the legacy payload for backwards compatibility const legacyPayload = { data: { - data: { answer: answerTml }, + data: answerTml, status: embedEventStatus.END, type: EmbedEvent.OnBeforeGetVizDataIntercept } From 0c33102440c1f4b8f82a485bd0f688d8704c7b83 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 20:27:47 +0530 Subject: [PATCH 25/28] SCAL-269016 : logic update --- src/embed/ts-embed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 8a30b777..0eab1ce9 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -343,7 +343,7 @@ export class TsEmbed { } const getUnsavedAnswerTml = async (props: { sessionId?: string, vizId?: string }) => { const response = await this.triggerUIPassThrough(UIPassthroughEvent.GetUnsavedAnswerTML, props); - return response[0]?.value; + return response.filter((item) => item.value)?.[0]?.value; } handleInterceptEvent({ eventData, executeEvent, viewConfig: this.viewConfig, getUnsavedAnswerTml }); } From 9c9c4061b3b56d512537b46e408d93a70408c351 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Wed, 29 Oct 2025 22:01:22 +0530 Subject: [PATCH 26/28] SCAL-269016 : pre render usecase --- src/embed/ts-embed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 0eab1ce9..1687fe5a 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -582,7 +582,7 @@ export class TsEmbed { protected getUpdateEmbedParamsObject() { let queryParams = this.getEmbedParamsObject(); - queryParams = { ...this.viewConfig, ...queryParams }; + queryParams = { ...this.viewConfig, ...queryParams, ...this.getAppInitData() }; return queryParams; } From 0e2ec3fcedbca3ab41e33b487ed7e5d2d338aa6a Mon Sep 17 00:00:00 2001 From: sastaachar Date: Thu, 30 Oct 2025 15:41:55 +0530 Subject: [PATCH 27/28] SCAL-269016 : nit @aditya --- src/types.ts | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/types.ts b/src/types.ts index b8323a8f..d24c2458 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2976,19 +2976,6 @@ export enum EmbedEvent { * Supported on all embed types. * * @example - * ```js - * embed.on(EmbedEvent.ApiIntercept, (payload, responder) => { - * console.log('payload', payload); - * responder({ - * data: { - * execute: false, - * error: { - * errorText: 'Error Occurred', - * } - * } - * }) - * }) - * ``` * * ```js * embed.on(EmbedEvent.ApiIntercept, (payload, responder) => { @@ -6074,7 +6061,7 @@ export type ApiInterceptFlags = { * * Can be used for Serach and App Embed from SDK 1.29.0 * - * @version SDK : 1.44.0 | ThoughtSpot: 10.15.0.cl + * @version SDK : 1.43.0 | ThoughtSpot: 10.15.0.cl */ isOnBeforeGetVizDataInterceptEnabled?: boolean; /** @@ -6090,7 +6077,7 @@ export type ApiInterceptFlags = { * }) * ``` * - * @version SDK : 1.44.0 | ThoughtSpot: 10.15.0.cl + * @version SDK : 1.43.0 | ThoughtSpot: 10.15.0.cl */ interceptUrls?: (string | InterceptedApiType)[]; /** @@ -6107,7 +6094,7 @@ export type ApiInterceptFlags = { * }) * ``` * - * @version SDK : 1.44.0 | ThoughtSpot: 10.15.0.cl + * @version SDK : 1.43.0 | ThoughtSpot: 10.15.0.cl */ interceptTimeout?: number; } From f024bfb909bd2e3bef526aa0933a94ca6ed50e09 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Mon, 3 Nov 2025 17:35:02 +0530 Subject: [PATCH 28/28] SCAL-269016 : ok --- src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index d24c2458..cd29cc0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3033,7 +3033,7 @@ export enum EmbedEvent { * }) * }) * ``` - * @version SDK: 1.42.0 | ThoughtSpot: 10.14.0.cl + * @version SDK: 1.43.0 | ThoughtSpot: 10.15.0.cl */ ApiIntercept = 'ApiIntercept', }