import { resetValueFromWindow } from '../utils'; import { ERROR_MESSAGE } from '../errors'; import { resetCachedAuthToken } from '../authToken'; import { AuthType, init, EmbedEvent, SearchEmbed, PinboardEmbed, LiveboardViewConfig, AppEmbed, LiveboardEmbed, AppViewConfig, SearchViewConfig, AnswerService, SpotterEmbed, SpotterEmbedViewConfig, } from '../index'; import { Action, HomeLeftNavItem, RuntimeFilter, RuntimeFilterOp, HomepageModule, HostEvent, RuntimeParameter, Param, ContextMenuTriggerOptions, CustomActionTarget, CustomActionsPosition, DefaultAppInitData, ErrorDetailsTypes, EmbedErrorCodes, EmbedErrorSeverity, ContextObject, } from '../types'; import { executeAfterWait, getDocumentBody, getIFrameEl, getIFrameSrc, getRootEl, postMessageToParent, defaultParamsForPinboardEmbed, waitFor, expectUrlMatchesWithParams, expectUrlToHaveParamsWithValues, mockMessageChannel, createRootEleForEmbed, expectUrlMatch, fixedEncodeURI, } from '../test/test-utils'; import * as config from '../config'; import * as embedConfig from './embedConfig'; import * as tsEmbedInstance from './ts-embed'; import * as mixpanelInstance from '../mixpanel-service'; import * as authInstance from '../auth'; import * as baseInstance from './base'; import { MIXPANEL_EVENT } from '../mixpanel-service'; import * as authService from '../utils/authService'; import { logger } from '../utils/logger'; import { version } from '../../package.json'; import { HiddenActionItemByDefaultForSearchEmbed } from './search'; import { processTrigger } from '../utils/processTrigger'; import { UIPassthroughEvent } from '../contracts/ui-passthrough-contracts'; import * as sessionInfoService from '../utils/sessionInfoService'; import * as authToken from '../authToken'; import * as apiIntercept from '../api-intercept'; import * as processData from '../utils/processData'; jest.mock('../utils/processTrigger'); const mockProcessTrigger = processTrigger as jest.Mock; const mockHandleInterceptEvent = jest.spyOn(apiIntercept, 'handleInterceptEvent'); const defaultViewConfig = { frameParams: { width: 1280, height: 720, }, }; const pinboardId = 'eca215d4-0d2c-4a55-90e3-d81ef6848ae0'; const liveboardId = 'eca215d4-0d2c-4a55-90e3-d81ef6848ae0'; const tabId1 = 'eca215d4-0d2c-4a55-90e3-d81ef6848ae0'; const tabId2 = 'eca215d4-0d2c-4a55-90e3-d81ef6848ae0'; const thoughtSpotHost = 'tshost'; const defaultParamsPost = ''; export const defaultParamsWithoutHiddenActions = `hostAppUrl=local-host&viewPortHeight=768&viewPortWidth=1024&sdkVersion=${version}&authType=${AuthType.None}&blockNonEmbedFullAppAccess=true`; export const defaultParams = `&${defaultParamsWithoutHiddenActions}&hideAction=[%22${Action.ReportError}%22]`; const hideBydefault = `&hideAction=${fixedEncodeURI( JSON.stringify([Action.ReportError, ...HiddenActionItemByDefaultForSearchEmbed]), )}`; const defaultParamsWithHiddenActions = defaultParamsWithoutHiddenActions + hideBydefault; beforeAll(() => { jest.spyOn(window, 'alert').mockImplementation(() => {}); }); const customisations = { style: { customCSS: {}, customCSSUrl: undefined as string | undefined, }, content: {}, }; const customisationsView = { style: { customCSS: {}, }, content: { strings: { DATA: 'data', }, }, }; const customVariablesForThirdPartyTools = { key1: '!@#', key2: '*%^', }; const getMockAppInitPayload = (data: any) => { const defaultData: DefaultAppInitData = { customisations, authToken: '', hostConfig: undefined, runtimeFilterParams: null, runtimeParameterParams: null, hiddenHomeLeftNavItems: [], hiddenHomepageModules: [], hiddenListColumns: [], customActions: [], reorderedHomepageModules: [], customVariablesForThirdPartyTools, interceptTimeout: undefined, interceptUrls: [], shouldBypassPayloadValidation: undefined, useHostEventsV2: undefined, embedExpiryInAuthToken: true, }; return { type: EmbedEvent.APP_INIT, data: { ...defaultData, ...data, }, }; }; describe('Unit test case for ts embed', () => { const mockMixPanelEvent = jest.spyOn(mixpanelInstance, 'uploadMixpanelEvent'); beforeEach(() => { document.body.innerHTML = getDocumentBody(); }); afterEach(() => { jest.clearAllMocks(); resetCachedAuthToken(); }); beforeAll(() => { jest.spyOn(authInstance, 'postLoginService').mockResolvedValue(undefined); }); describe('Vaidate iframe properties', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); }); test('should set proper allow policies', async () => { // we dont have origin specific policies so just checking if // policies are ending with ; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.render(); await executeAfterWait(() => { const iframe = getIFrameEl(); const policiesAdded = iframe.allow.split(' '); policiesAdded.forEach((policy) => { expect(policy.endsWith(';')).toBe(true); }); }); }); test('should get answer service', async () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.render(); mockProcessTrigger.mockResolvedValue({ session: 'test' }); await executeAfterWait(async () => { expect(await searchEmbed.getAnswerService()).toBeInstanceOf(AnswerService); }); }); test('triggerUIPassThrough with params', async () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.render(); mockProcessTrigger.mockResolvedValue({ session: 'test' }); await executeAfterWait(async () => { const payload = { newVizName: 'test' }; await searchEmbed.triggerUIPassThrough( UIPassthroughEvent.PinAnswerToLiveboard, payload, ); expect(mockProcessTrigger).toHaveBeenCalledWith( getIFrameEl(), HostEvent.UIPassthrough, 'http://tshost', { parameters: payload, type: UIPassthroughEvent.PinAnswerToLiveboard, }, undefined, ); }); }); test('Host event with empty param', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { liveboardId: '123', ...defaultViewConfig, }); liveboardEmbed.render(); mockProcessTrigger.mockResolvedValue({ session: 'test' }); await executeAfterWait(async () => { await liveboardEmbed.trigger(HostEvent.Save); expect(mockProcessTrigger).toHaveBeenCalledWith( getIFrameEl(), HostEvent.Save, 'http://tshost', {}, undefined, ); }); }); test('Host event with falsy param', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { liveboardId: '123', ...defaultViewConfig, }); liveboardEmbed.render(); mockProcessTrigger.mockResolvedValue({ session: 'test' }); await executeAfterWait(async () => { // `false` is a deliberately off-contract payload here — this // asserts raw passthrough to processTrigger, not a real Save // payload — so it is cast past the typed `data` parameter. await liveboardEmbed.trigger(HostEvent.Save, false as any); expect(mockProcessTrigger).toHaveBeenCalledWith( getIFrameEl(), HostEvent.Save, 'http://tshost', false, undefined, ); }); }); test('should set proper height, width and min-height to iframe', async () => { // we dont have origin specific policies so just checking if // policies are ending with ; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.render(); await executeAfterWait(() => { const iframe = getIFrameEl(); expect(iframe.style.width).toBe(`${defaultViewConfig.frameParams.width}px`); expect(iframe.style.height).toBe(`${defaultViewConfig.frameParams.height}px`); expect(iframe.style.minHeight).toBe(`${defaultViewConfig.frameParams.height}px`); }); }); }); describe('AuthExpire embedEvent in cookieless authentication authType', () => { beforeAll(() => { jest.spyOn(authInstance, 'doCookielessTokenAuth').mockResolvedValueOnce(true); jest.spyOn(authService, 'verifyTokenService').mockResolvedValueOnce(true); init({ thoughtSpotHost: 'tshost', customizations: customisations, authType: AuthType.TrustedAuthTokenCookieless, getAuthToken: () => Promise.resolve('test_auth_token2'), }); }); test('check for new authToken based on getAuthToken function', async () => { const mockEmbedEventPayload = { type: EmbedEvent.AuthExpire, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); jest.spyOn(baseInstance, 'notifyAuthFailure'); jest.spyOn(baseInstance, 'handleAuth'); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(baseInstance.notifyAuthFailure).toHaveBeenCalledWith( authInstance.AuthFailureType.EXPIRY, ); expect(baseInstance.handleAuth).not.toHaveBeenCalled(); expect(mockPort.postMessage).toHaveBeenCalledWith({ type: EmbedEvent.AuthExpire, data: { authToken: 'test_auth_token2' }, }); }); }); test('check for new authToken based on getAuthToken function', async () => { init({ thoughtSpotHost: 'tshost', customizations: customisations, authType: AuthType.TrustedAuthToken, getAuthToken: () => Promise.resolve('test_auth_token2'), autoLogin: true, }); const mockEmbedEventPayload = { type: EmbedEvent.AuthExpire, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); jest.spyOn(baseInstance, 'notifyAuthFailure'); jest.spyOn(baseInstance, 'handleAuth'); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(baseInstance.notifyAuthFailure).toHaveBeenCalledWith( authInstance.AuthFailureType.EXPIRY, ); expect(mockPort.postMessage).not.toHaveBeenCalledWith({ type: EmbedEvent.AuthExpire, data: { authToken: 'test_auth_token2' }, }); expect(baseInstance.handleAuth).toHaveBeenCalled(); }); }); }); describe('Called Embed event status for start and end', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, customizations: customisations, customVariablesForThirdPartyTools, }); }); test('verify Customisations', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({})); }); }); test('verify Customisations from viewConfig', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), { ...defaultViewConfig, customizations: customisationsView, }); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith( getMockAppInitPayload({ customisations: customisationsView, }), ); }); }); test('hide home page modules from view Config should be part of app_init payload', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const mockedHiddenHomepageModules: HomepageModule[] = [ HomepageModule.MyLibrary, HomepageModule.Learning, ]; const searchEmbed = new AppEmbed(getRootEl(), { ...defaultViewConfig, hiddenHomepageModules: mockedHiddenHomepageModules, }); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith( getMockAppInitPayload({ hiddenHomepageModules: [HomepageModule.MyLibrary, HomepageModule.Learning], }), ); }); }); test('customVariablesForThirdPartyTools should be part of the app_init payload', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const searchEmbed = new AppEmbed(getRootEl(), { ...defaultViewConfig, }); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({})); }); }); test('Reordering the home page modules from view Config should be part of app_init payload', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const mockedReorderedHomepageModules: HomepageModule[] = [ HomepageModule.MyLibrary, HomepageModule.Watchlist, ]; const searchEmbed = new AppEmbed(getRootEl(), { ...defaultViewConfig, reorderedHomepageModules: mockedReorderedHomepageModules, }); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith( getMockAppInitPayload({ reorderedHomepageModules: [ HomepageModule.MyLibrary, HomepageModule.Watchlist, ], }), ); }); }); test('Runtime parameters from view Config should be part of app_init payload when excludeRuntimeParametsfromURL is true', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const mockRuntimeParameters: RuntimeParameter[] = [ { name: 'color', value: 'blue', }, ]; const searchEmbed = new SearchEmbed(getRootEl(), { ...defaultViewConfig, excludeRuntimeParametersfromURL: true, runtimeParameters: mockRuntimeParameters, }); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith( getMockAppInitPayload({ runtimeParameterParams: 'param1=color¶mVal1=blue', }), ); }); }); test('Runtime filters from view Config should be part of app_init payload when excludeRuntimeFiltersfromURL is true', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const mockRuntimeFilters: RuntimeFilter[] = [ { columnName: 'color', operator: RuntimeFilterOp.EQ, values: ['blue'], }, ]; const searchEmbed = new SearchEmbed(getRootEl(), { ...defaultViewConfig, excludeRuntimeFiltersfromURL: true, runtimeFilters: mockRuntimeFilters, }); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith( getMockAppInitPayload({ runtimeFilterParams: 'col1=color&op1=EQ&val1=blue', }), ); }); }); test('Runtime filters from view Config should be not part of app_init payload when excludeRuntimeFiltersfromURL is undefined', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const mockRuntimeFilters: RuntimeFilter[] = [ { columnName: 'color', operator: RuntimeFilterOp.EQ, values: ['blue'], }, ]; const searchEmbed = new SearchEmbed(getRootEl(), { ...defaultViewConfig, runtimeFilters: mockRuntimeFilters, excludeRuntimeFiltersfromURL: undefined, }); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({})); }); }); test('Runtime filters from view Config should not be part of app_init payload when excludeRuntimeFiltersfromURL is false', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const mockRuntimeFilters: RuntimeFilter[] = [ { columnName: 'color', operator: RuntimeFilterOp.EQ, values: ['blue'], }, ]; const searchEmbed = new SearchEmbed(getRootEl(), { ...defaultViewConfig, excludeRuntimeFiltersfromURL: false, runtimeFilters: mockRuntimeFilters, }); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith(getMockAppInitPayload({})); }); }); test('homeLeftNav from view Config should be part of app_init payload', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const mockedHiddenHomeLeftNavItems: HomeLeftNavItem[] = [ HomeLeftNavItem.Home, HomeLeftNavItem.MonitorSubscription, ]; const searchEmbed = new AppEmbed(getRootEl(), { ...defaultViewConfig, hiddenHomeLeftNavItems: mockedHiddenHomeLeftNavItems, }); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith( getMockAppInitPayload({ hiddenHomeLeftNavItems: [ HomeLeftNavItem.Home, HomeLeftNavItem.MonitorSubscription, ], }), ); }); }); test.each([ ['not set', undefined, true], ['false', false, false], ['true', true, true], ] as [string, boolean | undefined, boolean][])( 'embedExpiryInAuthToken is %s when refreshAuthTokenOnNearExpiry is %s', async (_label, refreshAuthTokenOnNearExpiry, expectedEmbedExpiry) => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const searchEmbed = new AppEmbed(getRootEl(), { ...defaultViewConfig, refreshAuthTokenOnNearExpiry, }); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith( getMockAppInitPayload({ embedExpiryInAuthToken: expectedEmbedExpiry }), ); }); }, ); test('when Embed event status have start status', (done) => { const mockEmbedEventPayload = { type: EmbedEvent.Save, data: { answerId: '123' }, status: 'start', }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed .on( EmbedEvent.Save, (payload) => { expect(payload).toEqual(mockEmbedEventPayload); done(); }, { start: true }, ) .render(); executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload); }); }); test('should not called post message, when Embed event status have start and start option as false', () => { const mockEmbedEventPayload = { type: EmbedEvent.Save, data: { answerId: '123' }, status: 'start', }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed .on(EmbedEvent.Save, () => { logger.log('non callable'); }) .render(); executeAfterWait(() => { const iframe = getIFrameEl(); iframe.contentWindow.postMessage = jest.fn(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload); expect(iframe.contentWindow.postMessage).toHaveBeenCalledTimes(0); }); }); test('when Embed event status have end status', (done) => { const mockEmbedEventPayload = { type: EmbedEvent.Save, data: { answerId: '123' }, status: 'end', }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed .on(EmbedEvent.Save, (payload) => { expect(payload).toEqual(mockEmbedEventPayload); done(); }) .render(); executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload); }, 1000); }); test('should not called post message, when Embed event status have end status and start is true', () => { const mockEmbedEventPayload = { type: EmbedEvent.Save, data: { answerId: '123' }, status: 'end', }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed .on( EmbedEvent.Save, () => { logger.log('non callable'); }, { start: true }, ) .render(); executeAfterWait(() => { const iframe = getIFrameEl(); iframe.contentWindow.postMessage = jest.fn(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload); expect(iframe.contentWindow.postMessage).toHaveBeenCalledTimes(0); }, 1000); }); test('should remove event listener when called off method', async () => { const mockEmbedEventPayload = { type: EmbedEvent.Save, data: { answerId: '123' }, status: 'end', }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); const mockFn = jest.fn(); searchEmbed.on(EmbedEvent.Save, mockFn).render(); await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload); }); searchEmbed.off(EmbedEvent.Save, mockFn); await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload); }); expect(mockFn).toHaveBeenCalledTimes(1); }); }); describe('Appinit embedEvent in cookieless authentication authType', () => { beforeAll(() => { jest.spyOn(authInstance, 'doCookielessTokenAuth').mockResolvedValueOnce(true); init({ thoughtSpotHost: 'tshost', customizations: customisations, authType: AuthType.TrustedAuthTokenCookieless, getAuthToken: () => Promise.resolve('test_auth_token1'), }); }); afterEach(() => { baseInstance.reset(); }); test('check for authToken based on getAuthToken function', async () => { const a = jest.spyOn(authService, 'verifyTokenService'); a.mockResolvedValue(true); // authVerifyMock.mockResolvedValue(true); const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith( getMockAppInitPayload({ authToken: 'test_auth_token1', customVariablesForThirdPartyTools: {}, }), ); }); jest.spyOn(authService, 'verifyTokenService').mockClear(); }); }); describe('StringIDs and StringIDsUrl in customisations', () => { const customisationWithStringIds = { style: { customCSS: {}, }, content: { strings: { Liveboard: 'Dashboard', }, stringIDsUrl: 'https://sample-string-ids-url.com', stringIDs: { 'liveboard.header.title': 'Dashboard name', }, }, }; beforeEach(() => { jest.spyOn(authInstance, 'doCookielessTokenAuth').mockResolvedValueOnce(true); jest.spyOn(authService, 'verifyTokenService').mockResolvedValue(true); init({ thoughtSpotHost: 'tshost', customizations: customisationWithStringIds, authType: AuthType.TrustedAuthTokenCookieless, getAuthToken: () => Promise.resolve('test_auth_token1'), }); }); afterEach(() => { baseInstance.reset(); jest.clearAllMocks(); }); test('should pass stringIDsUrl and stringIDs in customisations during APP_INIT', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); expect(iframe.src).toContain( 'overrideStringIDsUrl=https://sample-string-ids-url.com', ); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith( getMockAppInitPayload({ customisations: { content: { strings: { Liveboard: 'Dashboard', }, stringIDsUrl: 'https://sample-string-ids-url.com', stringIDs: { 'liveboard.header.title': 'Dashboard name', }, }, style: { customCSS: {}, customCSSUrl: undefined, }, }, authToken: 'test_auth_token1', customVariablesForThirdPartyTools: {}, }), ); const customisationContent = mockPort.postMessage.mock.calls[0][0].data.customisations.content; expect(customisationContent.stringIDsUrl).toBe('https://sample-string-ids-url.com'); expect(customisationContent.stringIDs).toEqual({ 'liveboard.header.title': 'Dashboard name', }); }); }); test('should allow passing exposeTranslationIDs in viewConfig', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), { ...defaultViewConfig, exposeTranslationIDs: true, }); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); expect(iframe.src).toContain('exposeTranslationIDs=true'); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); }); }); describe('getDefaultAppInitData with CustomActionsValidationResult', () => { beforeEach(() => { jest.spyOn(authInstance, 'doCookielessTokenAuth').mockResolvedValueOnce(true); jest.spyOn(authService, 'verifyTokenService').mockResolvedValue(true); init({ thoughtSpotHost: 'tshost', authType: AuthType.TrustedAuthTokenCookieless, getAuthToken: () => Promise.resolve('test_auth_token1'), }); }); afterEach(() => { baseInstance.reset(); jest.clearAllMocks(); }); test('should handle valid custom actions and sort them by name in getDefaultAppInitData', async () => { const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; // Create a SearchEmbed with valid custom actions to test // CustomActionsValidationResult const searchEmbed = new SearchEmbed(getRootEl(), { ...defaultViewConfig, customActions: [ { id: 'action1', name: 'Valid Action', target: CustomActionTarget.LIVEBOARD, position: CustomActionsPosition.PRIMARY, metadataIds: { liveboardIds: ['lb123'] }, }, { id: 'action2', name: 'Another Valid Action', target: CustomActionTarget.VIZ, position: CustomActionsPosition.MENU, metadataIds: { vizIds: ['viz456'] }, }, ], }); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).toHaveBeenCalledWith( getMockAppInitPayload({ customisations: { content: {}, style: { customCSS: {}, customCSSUrl: undefined, }, }, authToken: 'test_auth_token1', 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 customVariablesForThirdPartyTools: {}, }), ); // Verify that CustomActionsValidationResult structure is // correct const appInitData = mockPort.postMessage.mock.calls[0][0].data; expect(appInitData.customActions).toHaveLength(2); expect(appInitData.customActions).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'action1', name: 'Valid Action', target: CustomActionTarget.LIVEBOARD, position: CustomActionsPosition.PRIMARY, }), expect.objectContaining({ id: 'action2', name: 'Another Valid Action', target: CustomActionTarget.VIZ, position: CustomActionsPosition.MENU, }), ]), ); // Verify actions are sorted by name (alphabetically) expect(appInitData.customActions[0].name).toBe('Another Valid Action'); expect(appInitData.customActions[1].name).toBe('Valid Action'); }); }); }); describe('Token fetch fails in cookieless authentication authType', () => { beforeEach(() => { jest.spyOn(authInstance, 'doCookielessTokenAuth').mockResolvedValueOnce(true); init({ thoughtSpotHost: 'tshost', customizations: customisations, authType: AuthType.TrustedAuthTokenCookieless, getAuthToken: () => Promise.reject(), }); jest.spyOn(logger, 'error').mockImplementation(() => {}); }); afterEach(() => { jest.clearAllMocks(); baseInstance.reset(); }); test('should show login failure message if token failed during app_init', async () => { const a = jest.spyOn(authService, 'verifyTokenService'); a.mockResolvedValue(true); // authVerifyMock.mockResolvedValue(true); const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(mockPort.postMessage).not.toHaveBeenCalled(); expect(getRootEl().innerHTML).toContain('Not logged in'); }); jest.spyOn(authService, 'verifyTokenService').mockClear(); }); test('should show login failure message if token failed during app_init prerender', async () => { const a = jest.spyOn(authService, 'verifyTokenService'); a.mockResolvedValue(true); // authVerifyMock.mockResolvedValue(true); const mockEmbedEventPayload = { type: EmbedEvent.APP_INIT, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), { ...defaultViewConfig, preRenderId: 'test', }); searchEmbed.preRender(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); const preRenderWrapper = document.getElementById('tsEmbed-pre-render-wrapper-test'); await executeAfterWait(() => { expect(mockPort.postMessage).not.toHaveBeenCalled(); expect(preRenderWrapper.innerHTML).toContain('Not logged in'); }); jest.spyOn(authService, 'verifyTokenService').mockClear(); }); test('should show login failure message if update token failed', async () => { const a = jest.spyOn(authService, 'verifyTokenService'); a.mockResolvedValue(true); // authVerifyMock.mockResolvedValue(true); const mockEmbedEventPayload = { type: EmbedEvent.AuthExpire, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); jest.spyOn(baseInstance, 'notifyAuthFailure'); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; const loggerSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}); await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(getRootEl().innerHTML).toContain('Not logged in'); expect(baseInstance.notifyAuthFailure).toHaveBeenCalledWith( authInstance.AuthFailureType.EXPIRY, ); expect(loggerSpy).toHaveBeenCalledTimes(1); }); jest.spyOn(authService, 'verifyTokenService').mockClear(); jest.spyOn(baseInstance, 'notifyAuthFailure').mockClear(); }); test('should show login failure message if update token failed prerender', async () => { const a = jest.spyOn(authService, 'verifyTokenService'); a.mockResolvedValue(true); // authVerifyMock.mockResolvedValue(true); const mockEmbedEventPayload = { type: EmbedEvent.AuthExpire, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), { ...defaultViewConfig, preRenderId: 'test', }); jest.spyOn(baseInstance, 'notifyAuthFailure'); searchEmbed.preRender(); const loggerSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); const preRenderWrapper = document.getElementById('tsEmbed-pre-render-wrapper-test'); await executeAfterWait(() => { expect(preRenderWrapper.innerHTML).toContain('Not logged in'); expect(baseInstance.notifyAuthFailure).toHaveBeenCalledWith( authInstance.AuthFailureType.EXPIRY, ); expect(loggerSpy).toHaveBeenCalledTimes(1); }); jest.spyOn(authService, 'verifyTokenService').mockClear(); jest.spyOn(baseInstance, 'notifyAuthFailure').mockClear(); }); }); xdescribe('AuthExpire embedEvent in TrustedAuthToken authType', () => { test('AutoLogin true scenario', async () => { init({ thoughtSpotHost: 'tshost', customizations: customisations, authType: AuthType.TrustedAuthToken, username: 'tsadmin', getAuthToken: () => Promise.resolve('test_auth_token3'), autoLogin: true, }); const mockEmbedEventPayload = { type: EmbedEvent.AuthExpire, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); jest.spyOn(baseInstance, 'notifyAuthFailure'); jest.spyOn(baseInstance, 'handleAuth'); searchEmbed.render(); await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload); }); await executeAfterWait(() => { expect(baseInstance.notifyAuthFailure).toHaveBeenCalledWith( authInstance.AuthFailureType.EXPIRY, ); expect(baseInstance.handleAuth).toHaveBeenCalled(); }); }); test('AutoLogin false scenario', async () => { init({ thoughtSpotHost: 'tshost', customizations: customisations, authType: AuthType.TrustedAuthToken, username: 'tsadmin', getAuthToken: () => Promise.resolve('test_auth_token4'), }); const mockEmbedEventPayload = { type: EmbedEvent.AuthExpire, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); jest.spyOn(baseInstance, 'notifyAuthFailure'); jest.spyOn(baseInstance, 'handleAuth'); searchEmbed.render(); await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload); }); await executeAfterWait(() => { expect(baseInstance.notifyAuthFailure).toHaveBeenCalledWith( authInstance.AuthFailureType.EXPIRY, ); expect(baseInstance.handleAuth).not.toHaveBeenCalled(); }); }); }); describe('when thoughtSpotHost have value and authPromise return response true/false', () => { beforeAll(() => { init({ thoughtSpotHost, authType: AuthType.None, loginFailedMessage: 'Failed to Login', }); }); const setup = async (isLoggedIn = false) => { jest.spyOn(window, 'addEventListener').mockImplementationOnce( (event, handler, options) => { (handler as EventListener)({ data: { type: 'xyz' }, ports: [3000], source: null, } as any); }, ); const iFrame: any = document.createElement('div'); jest.spyOn(baseInstance, 'getAuthPromise').mockResolvedValueOnce(isLoggedIn); const tsEmbed = new SearchEmbed(getRootEl(), {}); iFrame.contentWindow = null; tsEmbed.on(EmbedEvent.CustomAction, jest.fn()); jest.spyOn(iFrame, 'addEventListener').mockImplementationOnce( (event, handler, options) => { (handler as EventListener)({} as Event); }, ); jest.spyOn(document, 'createElement').mockReturnValueOnce(iFrame); await tsEmbed.render(); }; test('mixpanel should call with VISUAL_SDK_RENDER_COMPLETE', async () => { await setup(true); expect(mockMixPanelEvent).toHaveBeenCalledWith(MIXPANEL_EVENT.VISUAL_SDK_RENDER_START); expect(mockMixPanelEvent).toHaveBeenCalledWith( MIXPANEL_EVENT.VISUAL_SDK_RENDER_COMPLETE, expect.objectContaining({ elWidth: 0, elHeight: 0, }), ); }); test('Should remove prefetch iframe', async () => { await setup(true); const prefetchIframe = document.querySelectorAll('.prefetchIframe'); expect(prefetchIframe.length).toBe(0); }); test('Should render failure when login fails', async () => { await setup(false); expect(getRootEl().innerHTML).toContain('Failed to Login'); }); }); describe('Trigger infoSuccess event on iframe load', () => { beforeAll(() => { jest.clearAllMocks(); init({ thoughtSpotHost, authType: AuthType.None, loginFailedMessage: 'Failed to Login', }); }); const setup = async (isLoggedIn = false, overrideOrgId: number | undefined = undefined) => { jest.spyOn(window, 'addEventListener').mockImplementationOnce( (event, handler, options) => { (handler as EventListener)({ data: { type: 'xyz' }, ports: [3000], source: null, } as any); }, ); mockProcessTrigger.mockResolvedValueOnce({ session: 'test' }); // resetCachedPreauthInfo(); let mockGetPreauthInfo = null; if (overrideOrgId) { mockGetPreauthInfo = jest .spyOn(sessionInfoService, 'getPreauthInfo') .mockImplementation(jest.fn()); } const mockPreauthInfoFetch = jest .spyOn(authService, 'fetchPreauthInfoService') .mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), // Mock headers correctly json: async () => ({ info: { configInfo: { mixpanelConfig: { devSdkKey: 'devSdkKey', }, }, userGUID: 'userGUID', }, }), // Mock JSON response }); const iFrame: any = document.createElement('div'); jest.spyOn(baseInstance, 'getAuthPromise').mockResolvedValueOnce(isLoggedIn); const tsEmbed = new SearchEmbed(getRootEl(), { overrideOrgId, }); iFrame.contentWindow = { postMessage: jest.fn(), }; tsEmbed.on(EmbedEvent.CustomAction, jest.fn()); jest.spyOn(iFrame, 'addEventListener').mockImplementationOnce( (event, handler, options) => { (handler as EventListener)({} as Event); }, ); jest.spyOn(document, 'createElement').mockReturnValueOnce(iFrame); await tsEmbed.render(); return { mockPreauthInfoFetch, mockGetPreauthInfo, iFrame, }; }; test('should call InfoSuccess Event on preauth call success', async () => { const { mockPreauthInfoFetch, iFrame } = await setup(true); expect(mockPreauthInfoFetch).toHaveBeenCalledTimes(1); await executeAfterWait(() => { expect(mockProcessTrigger).toHaveBeenCalledWith( iFrame, HostEvent.InfoSuccess, 'http://tshost', expect.objectContaining({ info: expect.any(Object) }), undefined, ); }); }); test('should not call InfoSuccess Event if overrideOrgId is true', async () => { const { mockGetPreauthInfo } = await setup(true, 123); expect(mockGetPreauthInfo).toHaveBeenCalledTimes(0); }); test('should NOT trigger InfoSuccess when getPreauthInfo resolves with no .info field', async () => { jest.spyOn(window, 'addEventListener').mockImplementationOnce( (event, handler) => { (handler as EventListener)({ data: { type: 'xyz' }, ports: [3000], source: null, } as any); }, ); mockProcessTrigger.mockResolvedValueOnce({ session: 'test' }); jest.spyOn(authService, 'fetchPreauthInfoService').mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({}), // no .info field } as any); const iFrame: any = document.createElement('div'); jest.spyOn(baseInstance, 'getAuthPromise').mockResolvedValueOnce(true as any); const tsEmbed = new SearchEmbed(getRootEl(), {}); iFrame.contentWindow = { postMessage: jest.fn() }; jest.spyOn(iFrame, 'addEventListener').mockImplementationOnce( (event, handler) => { (handler as EventListener)({} as Event); }, ); jest.spyOn(document, 'createElement').mockReturnValueOnce(iFrame); await tsEmbed.render(); await executeAfterWait(() => { const infoSuccessCall = mockProcessTrigger.mock.calls.find( (call) => call[1] === HostEvent.InfoSuccess, ); expect(infoSuccessCall).toBeUndefined(); }); }); }); describe('Preauth Cache for FullAppEmbed with PrimaryNavBar', () => { beforeAll(() => { jest.clearAllMocks(); init({ thoughtSpotHost, authType: AuthType.None, }); }); afterEach(() => { jest.clearAllMocks(); }); afterAll(() => { jest.clearAllMocks(); }); const setupPreauthTest = async ( embedType: 'AppEmbed' | 'SearchEmbed', showPrimaryNavbar?: boolean, overrideOrgId?: number, disablePreauthCache?: boolean, ) => { jest.spyOn(window, 'addEventListener').mockImplementationOnce( (event, handler, options) => { (handler as EventListener)({ data: { type: 'xyz' }, ports: [3000], source: null, } as any); }, ); mockProcessTrigger.mockResolvedValueOnce({ session: 'test' }); jest.spyOn(baseInstance, 'getAuthPromise').mockResolvedValueOnce(true); let mockGetPreauthInfo = null; // Determine if preauth cache should be enabled const isAppEmbedWithPrimaryNavbar = embedType === 'AppEmbed' && showPrimaryNavbar === true; const shouldDisableCache = overrideOrgId || disablePreauthCache || isAppEmbedWithPrimaryNavbar; if (shouldDisableCache) { mockGetPreauthInfo = jest .spyOn(sessionInfoService, 'getPreauthInfo') .mockImplementation(jest.fn()); } else { mockGetPreauthInfo = jest .spyOn(sessionInfoService, 'getPreauthInfo') .mockResolvedValue({ info: { test: 'data' } } as any); } const mockPreauthInfoFetch = jest .spyOn(authService, 'fetchPreauthInfoService') .mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({ info: { test: 'data' }, }), } as any); const viewConfig: any = { frameParams: { width: 1280, height: 720 }, }; if (showPrimaryNavbar !== undefined) { viewConfig.showPrimaryNavbar = showPrimaryNavbar; } if (overrideOrgId !== undefined) { viewConfig.overrideOrgId = overrideOrgId; } // Mock getEmbedConfig for disablePreauthCache if (disablePreauthCache !== undefined) { jest.spyOn(embedConfig, 'getEmbedConfig').mockReturnValueOnce({ thoughtSpotHost, authType: AuthType.None, disablePreauthCache, }); } let embed; if (embedType === 'AppEmbed') { embed = new AppEmbed(getRootEl(), viewConfig); } else { embed = new SearchEmbed(getRootEl(), viewConfig); } const iFrame: any = document.createElement('div'); iFrame.contentWindow = { postMessage: jest.fn(), }; jest.spyOn(iFrame, 'addEventListener').mockImplementationOnce( (event, handler, options) => { (handler as EventListener)({} as Event); }, ); jest.spyOn(document, 'createElement').mockReturnValueOnce(iFrame); await embed.render(); return { embed, mockGetPreauthInfo, mockPreauthInfoFetch, iFrame, }; }; test('should disable preauth cache for FullAppEmbed with showPrimaryNavbar = true (default)', async () => { const { mockGetPreauthInfo } = await setupPreauthTest('AppEmbed', true); // Wait for any async operations await executeAfterWait(() => { expect(mockGetPreauthInfo).toHaveBeenCalledTimes(0); }); }); test('should enable preauth cache for FullAppEmbed with showPrimaryNavbar = undefined (no longer defaults to true)', async () => { const { mockGetPreauthInfo } = await setupPreauthTest('AppEmbed', undefined); await executeAfterWait(() => { expect(mockGetPreauthInfo).toHaveBeenCalledTimes(1); expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.InfoSuccess, 'http://tshost', expect.objectContaining({ info: expect.any(Object) }), undefined, ); }); }); test('should enable preauth cache for FullAppEmbed with showPrimaryNavbar = false', async () => { const { mockGetPreauthInfo } = await setupPreauthTest('AppEmbed', false); await executeAfterWait(() => { expect(mockGetPreauthInfo).toHaveBeenCalledTimes(1); expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.InfoSuccess, 'http://tshost', expect.objectContaining({ info: expect.any(Object) }), undefined, ); }); }); test('should enable preauth cache for SearchEmbed regardless of showPrimaryNavbar', async () => { const { mockGetPreauthInfo } = await setupPreauthTest('SearchEmbed', true); await executeAfterWait(() => { expect(mockGetPreauthInfo).toHaveBeenCalledTimes(1); expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.InfoSuccess, 'http://tshost', expect.objectContaining({ info: expect.any(Object) }), undefined, ); }); }); test('should enable preauth cache for SearchEmbed (verifies fix for embed type regression)', async () => { const { mockGetPreauthInfo } = await setupPreauthTest('SearchEmbed', false); await executeAfterWait(() => { expect(mockGetPreauthInfo).toHaveBeenCalledTimes(1); expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.InfoSuccess, 'http://tshost', expect.objectContaining({ info: expect.any(Object) }), undefined, ); }); }); test('should disable preauth cache for FullAppEmbed with overrideOrgId (combined condition)', async () => { const { mockGetPreauthInfo } = await setupPreauthTest('AppEmbed', false, 123); await executeAfterWait(() => { expect(mockGetPreauthInfo).toHaveBeenCalledTimes(0); }); }); test('should disable preauth cache for FullAppEmbed with disablePreauthCache = true', async () => { const { mockGetPreauthInfo } = await setupPreauthTest( 'AppEmbed', false, undefined, true, ); await executeAfterWait(() => { expect(mockGetPreauthInfo).toHaveBeenCalledTimes(0); }); }); }); describe('isFullAppEmbedWithVisiblePrimaryNavbar helper method', () => { beforeAll(() => { init({ thoughtSpotHost, authType: AuthType.None, }); }); afterEach(() => { jest.clearAllMocks(); }); test('should return true for AppEmbed with showPrimaryNavbar = true', () => { const appEmbed = new AppEmbed(getRootEl(), { showPrimaryNavbar: true }); expect(appEmbed['isFullAppEmbedWithVisiblePrimaryNavbar']()).toBe(true); }); test('should return false for AppEmbed with showPrimaryNavbar = undefined (no longer defaults to true)', () => { const appEmbed = new AppEmbed(getRootEl(), {}); expect(appEmbed['isFullAppEmbedWithVisiblePrimaryNavbar']()).toBe(false); }); test('should return false for AppEmbed with showPrimaryNavbar = false', () => { const appEmbed = new AppEmbed(getRootEl(), { showPrimaryNavbar: false }); expect(appEmbed['isFullAppEmbedWithVisiblePrimaryNavbar']()).toBe(false); }); test('should return false for SearchEmbed (not FullAppEmbed)', () => { const searchEmbed = new SearchEmbed(getRootEl(), {}); expect(searchEmbed['isFullAppEmbedWithVisiblePrimaryNavbar']()).toBe(false); }); test('should return false for LiveboardEmbed (not FullAppEmbed)', () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { liveboardId: 'test-id' }); expect(liveboardEmbed['isFullAppEmbedWithVisiblePrimaryNavbar']()).toBe(false); }); }); describe('when thoughtSpotHost have value and authPromise return error', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); }); beforeEach(async () => { jest.spyOn(baseInstance, 'getAuthPromise').mockRejectedValueOnce(false); const tsEmbed = new SearchEmbed(getRootEl(), {}); const iFrame: any = document.createElement('div'); iFrame.contentWindow = null; jest.spyOn(document, 'createElement').mockReturnValueOnce(iFrame); jest.spyOn(logger, 'error'); await tsEmbed.render(); }); test('mixpanel should call with VISUAL_SDK_RENDER_FAILED', () => { expect(mockMixPanelEvent).toHaveBeenCalledWith(MIXPANEL_EVENT.VISUAL_SDK_RENDER_START); expect(mockMixPanelEvent).toHaveBeenCalledWith( MIXPANEL_EVENT.VISUAL_SDK_RENDER_FAILED, { error: 'false', }, ); }); }); describe('when visible actions are set', () => { test('should throw error when there are both visible and hidden actions - pinboard', async () => { jest.spyOn(logger, 'error'); const pinboardEmbed = new PinboardEmbed(getRootEl(), { hiddenActions: [Action.DownloadAsCsv], visibleActions: [Action.DownloadAsCsv], ...defaultViewConfig, pinboardId, } as LiveboardViewConfig); await pinboardEmbed.render(); expect(pinboardEmbed['isError']).toBe(true); expect(logger.error).toHaveBeenCalledWith({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: ERROR_MESSAGE.CONFLICTING_ACTIONS_CONFIG, code: EmbedErrorCodes.CONFLICTING_ACTIONS_CONFIG, error: ERROR_MESSAGE.CONFLICTING_ACTIONS_CONFIG, }); }); test('should not throw error when there are only visible or hidden actions - pinboard', async () => { const pinboardEmbed = new PinboardEmbed(getRootEl(), { hiddenActions: [Action.DownloadAsCsv], ...defaultViewConfig, pinboardId, } as LiveboardViewConfig); pinboardEmbed.render(); expect(pinboardEmbed['isError']).toBe(false); }); /** * * @param hiddenActions * @param visibleActions */ async function testActionsForLiveboards( hiddenActions: Array, visibleActions: Array, ) { jest.spyOn(logger, 'error'); const liveboardEmbed = new LiveboardEmbed(getRootEl(), { hiddenActions, visibleActions, ...defaultViewConfig, liveboardId, } as LiveboardViewConfig); await liveboardEmbed.render(); expect(liveboardEmbed['isError']).toBe(true); expect(logger.error).toHaveBeenCalledWith({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: ERROR_MESSAGE.CONFLICTING_ACTIONS_CONFIG, code: EmbedErrorCodes.CONFLICTING_ACTIONS_CONFIG, error: ERROR_MESSAGE.CONFLICTING_ACTIONS_CONFIG, }); } test('should throw error when there are both visible and hidden action arrays', async () => { await testActionsForLiveboards([Action.DownloadAsCsv], [Action.DownloadAsCsv]); }); test('should throw error when there are both visible and hidden actions arrays as empty', async () => { await testActionsForLiveboards([], []); }); test('should throw error when there are both visible and hidden actions - one of them is an empty array', async () => { await testActionsForLiveboards([], [Action.DownloadAsCsv]); }); test('should not throw error when there are only visible or hidden actions', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { hiddenActions: [Action.DownloadAsCsv], ...defaultViewConfig, liveboardId, } as LiveboardViewConfig); liveboardEmbed.render(); expect(liveboardEmbed['isError']).toBe(false); }); test('should not throw error when there are only visible or hidden actions', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { visibleActions: [Action.DownloadAsCsv], ...defaultViewConfig, liveboardId, } as LiveboardViewConfig); liveboardEmbed.render(); expect(liveboardEmbed['isError']).toBe(false); }); }); describe('when visible Tabs are set', () => { test('should throw error when there are both visible and hidden Tabs - pinboard', async () => { jest.spyOn(logger, 'error'); const pinboardEmbed = new PinboardEmbed(getRootEl(), { visibleTabs: [tabId1], hiddenTabs: [tabId2], ...defaultViewConfig, pinboardId, } as LiveboardViewConfig); await pinboardEmbed.render(); expect(pinboardEmbed['isError']).toBe(true); expect(logger.error).toHaveBeenCalledWith({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: ERROR_MESSAGE.CONFLICTING_TABS_CONFIG, code: EmbedErrorCodes.CONFLICTING_TABS_CONFIG, error: ERROR_MESSAGE.CONFLICTING_TABS_CONFIG, }); }); test('should not throw error when there are only visible or hidden Tabs - pinboard', async () => { const pinboardEmbed = new PinboardEmbed(getRootEl(), { hiddenTabs: [tabId1], ...defaultViewConfig, pinboardId, } as LiveboardViewConfig); pinboardEmbed.render(); expect(pinboardEmbed['isError']).toBe(false); }); /** * * @param hiddenTabs * @param visibleTabs */ async function testTabsForLiveboards( hiddenTabs: Array, visibleTabs: Array, ) { jest.spyOn(logger, 'error'); const liveboardEmbed = new LiveboardEmbed(getRootEl(), { hiddenTabs, visibleTabs, ...defaultViewConfig, liveboardId, } as LiveboardViewConfig); await liveboardEmbed.render(); expect(liveboardEmbed['isError']).toBe(true); expect(logger.error).toHaveBeenCalledWith({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: ERROR_MESSAGE.CONFLICTING_TABS_CONFIG, code: EmbedErrorCodes.CONFLICTING_TABS_CONFIG, error: ERROR_MESSAGE.CONFLICTING_TABS_CONFIG, }); } test('should throw error when there are both visible and hidden Tab arrays', async () => { await testTabsForLiveboards([tabId1], [tabId2]); }); test('should throw error when there are both visible and hidden Tab arrays as empty', async () => { await testTabsForLiveboards([], []); }); test('should throw error when there are both visible and hidden Tabs - one of them is an empty array', async () => { await testTabsForLiveboards([], [tabId2]); }); test('should not throw error when there are only visible or hidden Tab', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { hiddenTabs: [tabId2], ...defaultViewConfig, liveboardId, } as LiveboardViewConfig); liveboardEmbed.render(); expect(liveboardEmbed['isError']).toBe(false); }); test('should not throw error when there are only visible or hidden Tabs', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { visibleTabs: [tabId1], ...defaultViewConfig, liveboardId, } as LiveboardViewConfig); liveboardEmbed.render(); expect(liveboardEmbed['isError']).toBe(false); }); }); describe('when thoughtSpotHost is empty', () => { beforeAll(() => { jest.spyOn(config, 'getThoughtSpotHost').mockImplementation(() => ''); init({ thoughtSpotHost: '', authType: AuthType.None, }); }); test('Error should be true', async () => { jest.spyOn(logger, 'error'); const tsEmbed = new SearchEmbed(getRootEl(), {}); await tsEmbed.render(); expect(tsEmbed['isError']).toBe(true); expect(logger.error).toHaveBeenCalledWith({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: ERROR_MESSAGE.INIT_SDK_REQUIRED, code: EmbedErrorCodes.INIT_ERROR, severity: EmbedErrorSeverity.SEV1, error: ERROR_MESSAGE.INIT_SDK_REQUIRED, }); }); test('a missing init is reported to the host as SEV1', async () => { const errorHandler = jest.fn(); const tsEmbed = new SearchEmbed(getRootEl(), {}); tsEmbed.on(EmbedEvent.Error, errorHandler); await tsEmbed.render(); expect(errorHandler.mock.calls[0][0]).toEqual( expect.objectContaining({ code: EmbedErrorCodes.INIT_ERROR, severity: EmbedErrorSeverity.SEV1, }), ); }); }); describe('error severity', () => { beforeEach(() => { jest.spyOn(config, 'getThoughtSpotHost').mockImplementation(() => 'http://tshost'); }); test('defaults to SEV3 when the raising site does not set one', () => { const errorHandler = jest.fn(); const tsEmbed = new SearchEmbed(getRootEl(), {}); tsEmbed.on(EmbedEvent.Error, errorHandler); (tsEmbed as any).handleError({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: 'no severity set', code: EmbedErrorCodes.HOST_EVENT_VALIDATION, }); expect(errorHandler.mock.calls[0][0]).toEqual( expect.objectContaining({ severity: EmbedErrorSeverity.SEV3 }), ); }); test('does not override a severity the raising site set', () => { const errorHandler = jest.fn(); const tsEmbed = new SearchEmbed(getRootEl(), {}); tsEmbed.on(EmbedEvent.Error, errorHandler); (tsEmbed as any).handleError({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: 'explicitly fatal', code: EmbedErrorCodes.HOST_EVENT_VALIDATION, severity: EmbedErrorSeverity.SEV1, }); expect(errorHandler.mock.calls[0][0]).toEqual( expect.objectContaining({ severity: EmbedErrorSeverity.SEV1 }), ); }); }); describe('V1Embed ', () => { beforeEach(() => { jest.spyOn(config, 'getThoughtSpotHost').mockImplementation(() => 'http://tshost'); }); test('when isRendered is true than isError will be true', async () => { jest.spyOn(logger, 'warn'); const viEmbedIns = new tsEmbedInstance.V1Embed(getRootEl(), defaultViewConfig); expect(viEmbedIns['isError']).toBe(false); await viEmbedIns.render(); viEmbedIns.on(EmbedEvent.CustomAction, jest.fn()).render(); expect(logger.warn).toHaveBeenCalledWith( 'Please register event handlers before calling render', ); }); test('Generates the correct url for V1Embed when V2 shell is enabled', async () => { const v1Embed = new LiveboardEmbed(getRootEl(), { ...defaultViewConfig, liveboardId: '123', enableV2Shell_experimental: true, }); await v1Embed.render(); await executeAfterWait(() => { expect(getIFrameSrc()).toContain('/v2/?'); }); }); }); describe('Navigate to Page API', () => { const path = 'viz/e0836cad-4fdf-42d4-bd97-567a6b2a6058'; beforeEach(() => { jest.spyOn(config, 'getThoughtSpotHost').mockImplementation(() => 'http://tshost'); }); test('when app is PinboardEmbed after navigateToPage function call, new path should be set to iframe', async () => { const pinboardEmbed = new PinboardEmbed(getRootEl(), { pinboardId: 'e0836cad-4fdf-42d4-bd97-567a6b2a6058', }); await pinboardEmbed.render(); // pinboardEmbed.navigateToPage(path); expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true&${defaultParamsForPinboardEmbed}&isLiveboardEmbed=true${defaultParamsPost}#/embed/${path}`, ); }); test('when app is AppEmbed after navigateToPage function call, new path should be set to iframe', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); await appEmbed.render(); appEmbed.navigateToPage(path, false); expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true&primaryNavHidden=true&profileAndHelpInNavBarHidden=false&${defaultParamsForPinboardEmbed}${defaultParamsPost}#/${path}`, ); }); test('navigateToPage function use before render', async () => { jest.spyOn(logger, 'log'); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); appEmbed.navigateToPage(path, false); await appEmbed.render(); expect(logger.log).toHaveBeenCalledWith( 'Please call render before invoking this method', ); }); }); describe('Navigate to Page API - Pinboard', () => { const path = 'pinboard/e0836cad-4fdf-42d4-bd97-567a6b2a6058'; beforeEach(() => { jest.spyOn(config, 'getThoughtSpotHost').mockImplementation(() => 'http://tshost'); }); test('when app is AppEmbed after navigateToPage function call, new path should be set to iframe', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); await appEmbed.render(); appEmbed.navigateToPage(path, false); expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true&primaryNavHidden=true&profileAndHelpInNavBarHidden=false&${defaultParamsForPinboardEmbed}${defaultParamsPost}#/${path}`, ); }); }); describe('get Encoded query param string', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, shouldEncodeUrlQueryParams: true, }); }); afterAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, shouldEncodeUrlQueryParams: false, }); }); it('should return the correct encoded query params string', async () => { const tsEmbed = new SearchEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); tsEmbed.render(); await waitFor(() => !!getIFrameEl()).then(() => { expect(getIFrameSrc()).toContain('?base64UrlEncodedFlags'); }); }); it('should return the correct encoded query params string when app is embeded', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); appEmbed.render(); await waitFor(() => !!getIFrameEl()).then(() => { expect(getIFrameSrc()).toContain('?base64UrlEncodedFlags'); }); }); }); describe('Iframe flags', () => { beforeEach(() => { jest.spyOn(config, 'getThoughtSpotHost').mockImplementation(() => 'http://tshost'); }); test('Set Frame params to the iframe as attributes', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', allowtransparency: true, }, }); await appEmbed.render(); const iframe = getIFrameEl(); expect(iframe.getAttribute('allowtransparency')).toBe('true'); }); it('should set the additional flags correctly on the iframe src', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, additionalFlags: { foo: 'bar', baz: 1, bool: true, }, }); await appEmbed.render(); expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true&primaryNavHidden=true&profileAndHelpInNavBarHidden=false&${defaultParamsForPinboardEmbed}` + `&foo=bar&baz=1&bool=true${defaultParamsPost}#/home`, ); }); it('should set the additional flags correctly on the iframe src from init and view config', async () => { init({ thoughtSpotHost: 'http://tshost', authType: AuthType.None, additionalFlags: { foo: 'bar1', foo2: 'bar2', foo3: false, }, }); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, additionalFlags: { foo: 'bar', baz: 1, bool: true, }, }); await appEmbed.render(); expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true&primaryNavHidden=true&profileAndHelpInNavBarHidden=false&${defaultParamsForPinboardEmbed}` + `&foo=bar&foo2=bar2&foo3=false&baz=1&bool=true${defaultParamsPost}#/home`, ); }); it('Sets the showAlerts param', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, showAlerts: true, }); await appEmbed.render(); expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true&primaryNavHidden=true&profileAndHelpInNavBarHidden=false&${defaultParamsForPinboardEmbed}` + `&showAlerts=true${defaultParamsPost}#/home`, ); }); it('Sets the locale param', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, locale: 'ja-JP', }); await appEmbed.render(); expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true&primaryNavHidden=true&profileAndHelpInNavBarHidden=false&${defaultParamsForPinboardEmbed}` + `&locale=ja-JP${defaultParamsPost}#/home`, ); }); it('Sets both params when enableLinkOverridesV2 is set', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, liveboardId: 'test-lb', enableLinkOverridesV2: true, }); await liveboardEmbed.render(); expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true&${defaultParamsForPinboardEmbed}&enableLinkOverridesV2=true&linkOverride=true${defaultParamsPost}#/embed/viz/test-lb`, ); }); it('Auto-upgrades V1 linkOverride to V2 (sends both flags)', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, liveboardId: 'test-lb', linkOverride: true, }); await liveboardEmbed.render(); const src = getIFrameSrc(); expect(src).toContain('linkOverride=true'); expect(src).toContain('enableLinkOverridesV2=true'); }); it('Auto-disables V2 link overrides when disableRedirectionLinksInNewTab is true', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, liveboardId: 'test-lb', enableLinkOverridesV2: true, disableRedirectionLinksInNewTab: true, }); await liveboardEmbed.render(); const src = getIFrameSrc(); expect(src).not.toContain('enableLinkOverridesV2=true'); expect(src).not.toContain('linkOverride=true'); expect(src).toContain('disableRedirectionLinksInNewTab=true'); }); it('Auto-disables V1 link override when disableRedirectionLinksInNewTab is true', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, liveboardId: 'test-lb', linkOverride: true, disableRedirectionLinksInNewTab: true, }); await liveboardEmbed.render(); const src = getIFrameSrc(); expect(src).not.toContain('linkOverride=true'); expect(src).toContain('disableRedirectionLinksInNewTab=true'); }); it('Sets the iconSprite url', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, customizations: { iconSpriteUrl: 'https://iconSprite.com', }, }); await appEmbed.render(); expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true&primaryNavHidden=true&profileAndHelpInNavBarHidden=false&${defaultParamsForPinboardEmbed}` + `&iconSprite=iconSprite.com${defaultParamsPost}#/home`, ); }); it('inserts as sibling of root node if configured', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, insertAsSibling: true, }); await appEmbed.render(); expect(getRootEl().nextSibling).toBe(getIFrameEl()); }); it('Should remove existing embed when rerendering', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, insertAsSibling: true, }); await appEmbed.render(); expect(getRootEl().nextSibling).toBe(getIFrameEl()); await appEmbed.render(); expect(getRootEl().nextSibling.nextSibling).not.toBe(getIFrameEl()); }); it('Should set the pendo tracking key when specified', async () => { jest.spyOn(baseInstance, 'getAuthPromise').mockResolvedValue(true); init({ thoughtSpotHost: 'tshost', authType: AuthType.None, pendoTrackingKey: '1234', }); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); await appEmbed.render(); expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true&primaryNavHidden=true&profileAndHelpInNavBarHidden=false&additionalPendoKey=1234${defaultParamsPost}#/home`, ); }); xit('Sets the forceSAMLAutoRedirect param', async () => { jest.spyOn(baseInstance, 'getAuthPromise').mockResolvedValue(true); init({ thoughtSpotHost: 'tshost', authType: AuthType.EmbeddedSSO, }); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); appEmbed.render(); expect(getIFrameSrc()).toContain('authType=EmbeddedSSO'); expect(getIFrameSrc()).toContain('forceSAMLAutoRedirect=true'); }); it('Should set the override locale for number/date and currency format', async () => { jest.spyOn(baseInstance, 'getAuthPromise').mockResolvedValue(true); init({ thoughtSpotHost: 'tshost', authType: AuthType.None, numberFormatLocale: 'en-US', dateFormatLocale: 'en-IN', currencyFormat: 'USD', }); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); await appEmbed.render(); expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true&primaryNavHidden=true&profileAndHelpInNavBarHidden=false&numberFormatLocale=en-US&dateFormatLocale=en-IN¤cyFormat=USD${defaultParamsPost}#/home`, ); }); it('Sets the overrideOrgId param', async () => { const overrideOrgId = 142536; const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, overrideOrgId, }); await appEmbed.render(); expectUrlToHaveParamsWithValues(getIFrameSrc(), { orgId: overrideOrgId, }); }); it('Sets the overrideHistoryState param', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, overrideHistoryState: true, }); await appEmbed.render(); expectUrlToHaveParamsWithValues(getIFrameSrc(), { overrideHistoryState: true, }); }); it('Sets the overrideHistoryState param to false', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, overrideHistoryState: false, }); await appEmbed.render(); expectUrlToHaveParamsWithValues(getIFrameSrc(), { overrideHistoryState: false, }); }); it('Should not add overrideHistoryState param when it is undefined', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); await appEmbed.render(); const url = getIFrameSrc(); expect(url).not.toContain('overrideHistoryState'); }); it('Should not add contextMenuEnabledOnWhichClick flag to the iframe src when it is not passed', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { ...defaultViewConfig, liveboardId, } as LiveboardViewConfig); liveboardEmbed.render(); await executeAfterWait(() => { expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true${defaultParams}#/embed/viz/${liveboardId}`, ); }); }); it('Should add contextMenuEnabledOnWhichClick flag to the iframe with left value', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { ...defaultViewConfig, liveboardId, contextMenuTrigger: ContextMenuTriggerOptions.LEFT_CLICK, } as LiveboardViewConfig); liveboardEmbed.render(); await executeAfterWait(() => { expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true${defaultParams}&contextMenuEnabledOnWhichClick=left#/embed/viz/${liveboardId}`, ); }); }); it('Should add contextMenuEnabledOnWhichClick flag to the iframe with right value', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { ...defaultViewConfig, liveboardId, contextMenuTrigger: ContextMenuTriggerOptions.RIGHT_CLICK, } as LiveboardViewConfig); liveboardEmbed.render(); await executeAfterWait(() => { expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true${defaultParams}&contextMenuEnabledOnWhichClick=right#/embed/viz/${liveboardId}`, ); }); }); it('Should add contextMenuEnabledOnWhichClick flag to the iframe with both value', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { ...defaultViewConfig, liveboardId, contextMenuTrigger: ContextMenuTriggerOptions.BOTH_CLICKS, } as LiveboardViewConfig); liveboardEmbed.render(); await executeAfterWait(() => { expectUrlMatchesWithParams( getIFrameSrc(), `http://${thoughtSpotHost}/?embedApp=true${defaultParams}&contextMenuEnabledOnWhichClick=both#/embed/viz/${liveboardId}`, ); }); }); }); describe('validate getThoughtSpotPostUrlParams', () => { const { location } = window; beforeAll(() => { delete window.location; (window as any).location = { assign: jest.fn(), }; }); beforeEach(() => { jest.spyOn(config, 'getThoughtSpotHost').mockImplementation(() => 'http://tshost'); }); afterAll((): void => { (window.location as any) = location; }); it('get url params for TS', () => { const tsEmbed = new tsEmbedInstance.TsEmbed(getRootEl(), defaultViewConfig); const urlHash = '#/analyze?ts-app=thoughtspot&ts-id=123&title=embed-sdk'; window.location.hash = urlHash; const postHashParams = '?ts-app=thoughtspot&ts-id=123'; expect(tsEmbed.getThoughtSpotPostUrlParams()).toBe(postHashParams); }); it('validate query params and postHash params for TS', () => { const tsEmbed = new tsEmbedInstance.TsEmbed(getRootEl(), defaultViewConfig); const urlHash = '#/analyze?ts-app=thoughtspot&ts-id=123&title=embed-sdk'; window.location.hash = urlHash; const urlSearch = '?ts-type=subscribe&search-title=abc'; window.location.search = urlSearch; const postHashParams = '?ts-type=subscribe&ts-app=thoughtspot&ts-id=123'; expect(tsEmbed.getThoughtSpotPostUrlParams()).toBe(postHashParams); }); }); describe('Block full app access while naviagting from embed app', () => { it('should contain blockNonEmbedFullAppAccess as false in query params', async () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, blockNonEmbedFullAppAccess: false, }); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); appEmbed.render(); await waitFor(() => !!getIFrameEl()).then(() => { expect(getIFrameSrc()).toContain('blockNonEmbedFullAppAccess=false'); }); }); it('should contain blockNonEmbedFullAppAccess as true in query params', async () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); appEmbed.render(); await waitFor(() => !!getIFrameEl()).then(() => { expect(getIFrameSrc()).toContain('blockNonEmbedFullAppAccess=true'); }); }); }); describe('validate preRender flow', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); }); afterAll(() => { const rootEle = document.getElementById('myRoot'); rootEle?.remove(); jest.clearAllMocks(); }); it('should preRender and hide the iframe', async () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'i-am-preRendered', liveboardId: 'myLiveboardId', }); libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); expect(preRenderWrapper.style.opacity).toBe('0'); expect(preRenderWrapper.style.pointerEvents).toBe('none'); expect(preRenderWrapper.style.zIndex).toBe('-1000'); const preRenderChild = document.getElementById(preRenderIds.child) as HTMLIFrameElement; expect(preRenderWrapper.children[0]).toEqual(preRenderChild); expect(preRenderChild).toBeInstanceOf(HTMLIFrameElement); expect(preRenderChild.src).toMatch(/^http:\/\/tshost.*\/myLiveboardId/); const tsEmbedDiv = document.getElementById('tsEmbedDiv'); tsEmbedDiv.style.width = '100px'; tsEmbedDiv.style.height = '100px'; let resizeObserverCb: any; (window as any).ResizeObserver = window.ResizeObserver || jest.fn().mockImplementation((resizeObserverCbParam) => { resizeObserverCb = resizeObserverCbParam; return { disconnect: jest.fn(), observe: jest.fn(), unobserve: jest.fn(), }; }); // show preRender const warnSpy = jest.spyOn(logger, 'warn'); libEmbed.showPreRender(); expect(warnSpy).toHaveBeenCalledTimes(1); // The ResizeObserver now tracks the placeholder inside this.el, // not this.el itself, so pass it as the target. const preRenderPlaceholder = tsEmbedDiv.firstElementChild as HTMLElement; preRenderPlaceholder.getBoundingClientRect = jest.fn().mockReturnValue({ x: 0, y: 0, width: 987, height: 297, top: 0, left: 0, bottom: 297, right: 987, }); resizeObserverCb([ { target: preRenderPlaceholder, contentRect: { height: 297, width: 987 }, }, ]); expect(preRenderWrapper.style.height).toEqual(`${297}px`); expect(preRenderWrapper.style.width).toEqual(`${987}px`); expect(preRenderWrapper.style.opacity).toBe(''); expect(preRenderWrapper.style.pointerEvents).toBe(''); expect(preRenderWrapper.style.zIndex).toBe(''); libEmbed.hidePreRender(); expect(preRenderWrapper.style.opacity).toBe('0'); expect(preRenderWrapper.style.pointerEvents).toBe('none'); expect(preRenderWrapper.style.zIndex).toBe('-1000'); libEmbed.destroy(); expect(document.getElementById(preRenderIds.wrapper)).toBe(null); }); it('preRender called without preRenderId should log error ', () => { createRootEleForEmbed(); jest.spyOn(logger, 'error'); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', }); libEmbed.preRender(); expect(logger.error).toHaveBeenCalledWith('PreRender ID is required for preRender'); }); it('showPreRender should preRender if not available', async () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'i-am-preRendered', liveboardId: 'myLiveboardId', }); const preRenderIds = libEmbed.getPreRenderIds(); libEmbed.showPreRender(); await waitFor(() => !!getIFrameEl()); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); expect(preRenderWrapper.style.opacity).toBe(''); expect(preRenderWrapper.style.pointerEvents).toBe(''); expect(preRenderWrapper.style.zIndex).toBe(''); }); it('hidePreRender should not preRender if not available', async () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'i-am-preRendered', liveboardId: 'myLiveboardId', }); jest.spyOn(libEmbed, 'preRender'); libEmbed.hidePreRender(); expect(libEmbed.preRender).toHaveBeenCalledTimes(0); }); it('should set overflow:hidden when hidePreRender and remove when showPreRender', async () => { createRootEleForEmbed(); (window as any).ResizeObserver = window.ResizeObserver || jest.fn().mockImplementation(() => ({ disconnect: jest.fn(), observe: jest.fn(), unobserve: jest.fn(), })); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'overflow-test', liveboardId: 'myLiveboardId', }); await libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); // After preRender (calls hidePreRender by default) // should have overflow:hidden expect(preRenderWrapper.style.overflow).toBe('hidden'); expect(preRenderWrapper.style.opacity).toBe('0'); // After showPreRender, overflow should be removed // to inherit from CSS libEmbed.showPreRender(); expect(preRenderWrapper.style.overflow).toBe(''); expect(preRenderWrapper.style.opacity).toBe(''); // After hidePreRender again, overflow should be hidden libEmbed.hidePreRender(); expect(preRenderWrapper.style.overflow).toBe('hidden'); expect(preRenderWrapper.style.opacity).toBe('0'); }); it('it should connect with another object', async () => { createRootEleForEmbed(); mockMessageChannel(); (window as any).ResizeObserver = window.ResizeObserver || jest.fn().mockImplementation(() => ({ disconnect: jest.fn(), observe: jest.fn(), unobserve: jest.fn(), })); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'i-am-preRendered', liveboardId: 'myLiveboardId', }); libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const newEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'i-am-preRendered', liveboardId: 'awdawda', hiddenActions: [Action.AddFilter], frameParams: { height: 90 }, }); await newEmbed.showPreRender(); // Verify newEmbed successfully connected to the existing preRender expect((newEmbed as any).isPreRenderConnected()).toBe(true); }); it('showPreRender should not preRender if not available', async () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', }); jest.spyOn(libEmbed, 'preRender'); jest.spyOn(logger, 'error'); libEmbed.showPreRender(); expect(libEmbed.preRender).toHaveBeenCalledTimes(0); expect(logger.error).toHaveBeenCalledTimes(1); }); it('should get underlying iframe', async () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', }); libEmbed.render(); await waitFor(() => !!getIFrameEl()); expect(libEmbed.getUnderlyingFrameElement()).toEqual(getIFrameEl()); }); it('should render error message properly', async () => { jest.spyOn(baseInstance, 'getAuthPromise').mockResolvedValueOnce(false); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', preRenderId: 'test', }); await libEmbed.preRender(); expect(document.getElementById('tsEmbed-pre-render-child-test').innerHTML).toBe( 'Not logged in', ); }); it('should log error if sync is called before preRender', async () => { jest.spyOn(logger, 'error').mockImplementation(jest.fn()); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', preRenderId: 'test', }); await libEmbed.syncPreRenderStyle(); expect(logger.error).toHaveBeenCalledWith( 'PreRender should be called before using syncPreRenderStyle', ); (logger.error as any).mockClear(); }); it('should skip syncing styles when the placeholder is detached', async () => { createRootEleForEmbed(); jest.spyOn(logger, 'debug').mockImplementation(jest.fn()); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', preRenderId: 'placeholder-detached', doNotTrackPreRenderSize: true, }); libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); await libEmbed.showPreRender(); const preRenderIds = libEmbed.getPreRenderIds(); const wrapper = document.getElementById(preRenderIds.wrapper); const placeholder = document.getElementById(preRenderIds.placeHolder); const rectSpy = jest.spyOn(placeholder, 'getBoundingClientRect'); wrapper.style.top = '42px'; wrapper.style.height = '111px'; (logger.debug as any).mockClear(); // Simulate the host subtree being unmounted: the placeholder is no // longer connected to the document. placeholder.remove(); libEmbed.syncPreRenderStyle(); expect(logger.debug).toHaveBeenCalledWith( 'syncPreRenderStyle skipped: placeholder is detached', ); // The detached node is never measured, so the wrapper keeps its // last known geometry instead of collapsing to a stale rect. expect(rectSpy).not.toHaveBeenCalled(); expect(wrapper.style.top).toBe('42px'); expect(wrapper.style.height).toBe('111px'); libEmbed.destroy(); (logger.debug as any).mockRestore(); }); describe('preRenderContainer', () => { it('should append preRenderWrapper to document.body when preRenderContainer is not set', async () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-default', liveboardId: 'myLiveboardId', }); await libEmbed.preRender(); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); expect(document.body.contains(preRenderWrapper)).toBe(true); }); it('should append preRenderWrapper to the element matching a CSS selector string', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'custom-pre-render-container'; document.body.appendChild(customContainer); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-selector', liveboardId: 'myLiveboardId', preRenderContainer: '#custom-pre-render-container', }); await libEmbed.preRender(); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); expect(customContainer.contains(preRenderWrapper)).toBe(true); customContainer.remove(); }); it('should fall back to document.body when the CSS selector string matches nothing', async () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-selector-miss', liveboardId: 'myLiveboardId', preRenderContainer: '#does-not-exist', }); await libEmbed.preRender(); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); expect(document.body.contains(preRenderWrapper)).toBe(true); }); it('should fall back to document.body and log an error when the CSS selector is invalid', async () => { createRootEleForEmbed(); jest.spyOn(logger, 'error').mockImplementation(jest.fn()); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-selector-invalid', liveboardId: 'myLiveboardId', preRenderContainer: '###invalid###', }); await libEmbed.preRender(); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); expect(document.body.contains(preRenderWrapper)).toBe(true); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining('Invalid CSS selector for preRenderContainer'), expect.any(Error), ); (logger.error as any).mockClear(); }); it('should append preRenderWrapper to an Element passed directly as preRenderContainer', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'custom-pre-render-element'; document.body.appendChild(customContainer); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-element', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await libEmbed.preRender(); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); expect(customContainer.contains(preRenderWrapper)).toBe(true); customContainer.remove(); }); it('should set the container position to relative when it is static', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'custom-pre-render-static'; customContainer.style.position = 'static'; document.body.appendChild(customContainer); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-position-static', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await libEmbed.preRender(); expect(customContainer.style.position).toBe('relative'); customContainer.remove(); }); it('should restore the original container position on destroy', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'custom-pre-render-restore'; customContainer.style.position = 'static'; document.body.appendChild(customContainer); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-position-restore', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await libEmbed.preRender(); expect(customContainer.style.position).toBe('relative'); libEmbed.destroy(); // Reverts to the exact value the container had before. expect(customContainer.style.position).toBe('static'); // The custom container reference is dropped (back to the default // body) so a destroyed embed does not pin the (possibly // detached) element in memory. expect((libEmbed as any).preRenderContainerEl).toBe(document.body); customContainer.remove(); }); it('should not touch the container position when it is already positioned', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'custom-pre-render-positioned'; customContainer.style.position = 'absolute'; document.body.appendChild(customContainer); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-position-positioned', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await libEmbed.preRender(); expect(customContainer.style.position).toBe('absolute'); libEmbed.destroy(); expect(customContainer.style.position).toBe('absolute'); customContainer.remove(); }); it('should default preRenderContainerEl to document.body before rendering', () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', }); // document.body is the "no custom container" sentinel, so the // field is never null and needs no null-guarding downstream. expect((libEmbed as any).preRenderContainerEl).toBe(document.body); }); it('should leave document.body untouched on destroy when no custom container is set', async () => { createRootEleForEmbed(); const originalBodyPosition = document.body.style.position; const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-body-destroy', liveboardId: 'myLiveboardId', }); await libEmbed.preRender(); expect((libEmbed as any).preRenderContainerEl).toBe(document.body); libEmbed.destroy(); // The body sentinel must never be treated as a custom container: // no position override to apply and none to restore. expect(document.body.style.position).toBe(originalBodyPosition); expect(document.body.dataset.tsEmbedOriginalPosition).toBeUndefined(); }); it('should keep the container positioned on destroy while another preRender wrapper remains', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'custom-pre-render-shared'; customContainer.style.position = 'static'; document.body.appendChild(customContainer); const firstEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-shared-first', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await firstEmbed.preRender(); const secondEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-shared-second', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await secondEmbed.preRender(); expect(customContainer.style.position).toBe('relative'); // Destroying the first embed must not strip the positioning // context the second embed still relies on. firstEmbed.destroy(); expect(customContainer.style.position).toBe('relative'); // Once the last embed is destroyed, the override is reverted. secondEmbed.destroy(); expect(customContainer.style.position).toBe('static'); customContainer.remove(); }); it('should resolve a selector container inside the host shadow root', async () => { const host = document.createElement('div'); document.body.appendChild(host); const shadow = host.attachShadow({ mode: 'open' }); const hostEl = document.createElement('div'); const shadowContainer = document.createElement('div'); shadowContainer.id = 'shadow-pre-render-container'; shadow.appendChild(hostEl); shadow.appendChild(shadowContainer); const libEmbed = new LiveboardEmbed(hostEl, { preRenderId: 'container-shadow', liveboardId: 'myLiveboardId', preRenderContainer: '#shadow-pre-render-container', }); await libEmbed.preRender(); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = shadow.querySelector(`#${preRenderIds.wrapper}`); // The wrapper lands in the shadow container, not the body. expect(shadowContainer.contains(preRenderWrapper)).toBe(true); expect(document.getElementById(preRenderIds.wrapper)).toBeNull(); host.remove(); }); it('should re-attach the wrapper when a selector container is remounted', async () => { createRootEleForEmbed(); const oldContainer = document.createElement('div'); oldContainer.id = 'remount-container'; document.body.appendChild(oldContainer); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-remount', liveboardId: 'myLiveboardId', preRenderContainer: '#remount-container', doNotTrackPreRenderSize: true, }); libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); await libEmbed.showPreRender(); const preRenderIds = libEmbed.getPreRenderIds(); const wrapper = document.getElementById(preRenderIds.wrapper); expect(oldContainer.contains(wrapper)).toBe(true); // Simulate React remounting the container: the old node (with // our wrapper) is detached and a fresh node takes its place. const removeSpy = jest.spyOn(oldContainer, 'removeEventListener'); oldContainer.remove(); const newContainer = document.createElement('div'); newContainer.id = 'remount-container'; document.body.appendChild(newContainer); const addSpy = jest.spyOn(newContainer, 'addEventListener'); // A reposition (scroll/resize/re-show) heals the stale ref. libEmbed.syncPreRenderStyle(); expect(newContainer.contains(wrapper)).toBe(true); expect(oldContainer.contains(wrapper)).toBe(false); // The scroll listener is migrated to the live container. expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); libEmbed.destroy(); newContainer.remove(); }); it('should position the newly resolved container after a remount, not the stale one', async () => { createRootEleForEmbed(); const oldContainer = document.createElement('div'); oldContainer.id = 'reposition-remount-container'; oldContainer.style.position = 'static'; document.body.appendChild(oldContainer); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-remount-position', liveboardId: 'myLiveboardId', preRenderContainer: '#reposition-remount-container', doNotTrackPreRenderSize: true, }); libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); await libEmbed.showPreRender(); expect(oldContainer.style.position).toBe('relative'); oldContainer.remove(); const newContainer = document.createElement('div'); newContainer.id = 'reposition-remount-container'; newContainer.style.position = 'static'; document.body.appendChild(newContainer); libEmbed.syncPreRenderStyle(); // applyPreRenderContainerPositioning() reads preRenderContainerEl // instead of taking the container as an argument, so reconcile has // to assign the field before calling it — otherwise the override // lands on the detached node and the fresh one stays static. expect((libEmbed as any).preRenderContainerEl).toBe(newContainer); expect(newContainer.style.position).toBe('relative'); expect(newContainer.dataset.tsEmbedOriginalPosition).toBe('static'); libEmbed.destroy(); newContainer.remove(); }); it('should leave an element-passed container untouched when it is detached', async () => { createRootEleForEmbed(); const container = document.createElement('div'); document.body.appendChild(container); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'container-element-remount', liveboardId: 'myLiveboardId', preRenderContainer: container, doNotTrackPreRenderSize: true, }); libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); await libEmbed.showPreRender(); const preRenderIds = libEmbed.getPreRenderIds(); const wrapper = document.getElementById(preRenderIds.wrapper); expect(container.contains(wrapper)).toBe(true); // Detach the element container; it cannot be re-resolved, so // the wrapper stays with it (no crash, no body fallback). container.remove(); libEmbed.syncPreRenderStyle(); expect(container.contains(wrapper)).toBe(true); libEmbed.destroy(); }); describe('connectPreRendered container init', () => { beforeEach(() => { (window as any).ResizeObserver = jest.fn().mockImplementation(() => ({ disconnect: jest.fn(), observe: jest.fn(), unobserve: jest.fn(), })); }); it('should resolve preRenderContainerEl when connecting to an existing pre-render', async () => { createRootEleForEmbed(); const firstEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-init-default', liveboardId: 'myLiveboardId', }); await firstEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const connectingEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-init-default', liveboardId: 'myLiveboardId', }); await connectingEmbed.showPreRender(); expect((connectingEmbed as any).preRenderContainerEl).not.toBeNull(); firstEmbed.destroy(); }); it('should fall back to document.body when no preRenderContainer is configured on the connecting embed', async () => { createRootEleForEmbed(); const firstEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-init-body', liveboardId: 'myLiveboardId', }); await firstEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const connectingEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-init-body', liveboardId: 'myLiveboardId', }); await connectingEmbed.showPreRender(); expect((connectingEmbed as any).preRenderContainerEl).toBe(document.body); firstEmbed.destroy(); }); it('should resolve a custom preRenderContainer on the connecting embed', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'connect-custom-container'; document.body.appendChild(customContainer); const firstEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-init-custom', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await firstEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const connectingEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-init-custom', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await connectingEmbed.showPreRender(); expect((connectingEmbed as any).preRenderContainerEl).toBe(customContainer); firstEmbed.destroy(); customContainer.remove(); }); it('should apply container positioning when the connecting embed resolves a static container', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'connect-static-container'; customContainer.style.position = 'static'; document.body.appendChild(customContainer); const firstEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-init-static', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await firstEmbed.preRender(); await waitFor(() => !!getIFrameEl()); // Reset position to static so the connecting embed also triggers the guard. customContainer.style.position = 'static'; delete customContainer.dataset['preRenderContainerOriginalPosition']; const connectingEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-init-static', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await connectingEmbed.showPreRender(); expect(customContainer.style.position).toBe('relative'); firstEmbed.destroy(); connectingEmbed.destroy(); customContainer.remove(); }); it('should inherit the container from the pre-rendering embed instead of re-resolving it', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'connect-inherit-container'; document.body.appendChild(customContainer); const firstEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-no-re-resolve', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await firstEmbed.preRender(); await waitFor(() => !!getIFrameEl()); // No preRenderContainer here: the container belongs to the // PreRender component and is adopted as-is. const connectingEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-no-re-resolve', liveboardId: 'myLiveboardId', }); const resolveSpy = jest.spyOn( connectingEmbed as any, 'resolvePreRenderContainerTarget', ); (connectingEmbed as any).connectPreRendered(); expect((connectingEmbed as any).preRenderContainerEl).toBe(customContainer); expect(resolveSpy).not.toHaveBeenCalled(); firstEmbed.destroy(); customContainer.remove(); }); it('should warn and ignore a preRenderContainer passed on the connecting embed', async () => { createRootEleForEmbed(); const ownerContainer = document.createElement('div'); ownerContainer.id = 'connect-owner-container'; const otherContainer = document.createElement('div'); otherContainer.id = 'connect-other-container'; document.body.appendChild(ownerContainer); document.body.appendChild(otherContainer); const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); const firstEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-container-warn', liveboardId: 'myLiveboardId', preRenderContainer: ownerContainer, }); await firstEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const connectingEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-container-warn', liveboardId: 'myLiveboardId', preRenderContainer: otherContainer, }); (connectingEmbed as any).connectPreRendered(); expect((connectingEmbed as any).preRenderContainerEl).toBe(ownerContainer); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('preRenderContainer is applied only by'), ); warnSpy.mockRestore(); firstEmbed.destroy(); ownerContainer.remove(); otherContainer.remove(); }); it('should not warn when the connecting embed passes the same container element', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'connect-same-element-container'; document.body.appendChild(customContainer); const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); const firstEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-same-element', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await firstEmbed.preRender(); await waitFor(() => !!getIFrameEl()); // Same config on both components — the value is redundant but // not wrong, so nagging about it would be noise. const connectingEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-same-element', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); (connectingEmbed as any).connectPreRendered(); expect((connectingEmbed as any).preRenderContainerEl).toBe(customContainer); expect(warnSpy).not.toHaveBeenCalledWith( expect.stringContaining('preRenderContainer is applied only by'), ); warnSpy.mockRestore(); firstEmbed.destroy(); customContainer.remove(); }); it('should not warn when the connecting embed passes a selector for the same container', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'connect-same-selector-container'; document.body.appendChild(customContainer); const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); const firstEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-same-selector', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await firstEmbed.preRender(); await waitFor(() => !!getIFrameEl()); // A selector that resolves to the owner's element is the same // container, so the comparison must be by resolved node. const connectingEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-same-selector', liveboardId: 'myLiveboardId', preRenderContainer: '#connect-same-selector-container', }); (connectingEmbed as any).connectPreRendered(); expect((connectingEmbed as any).preRenderContainerEl).toBe(customContainer); expect(warnSpy).not.toHaveBeenCalledWith( expect.stringContaining('preRenderContainer is applied only by'), ); warnSpy.mockRestore(); firstEmbed.destroy(); customContainer.remove(); }); it('should keep its own container when the same instance owns the pre-render', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'connect-self-container'; document.body.appendChild(customContainer); const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-self', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); // preRender() then render() on one instance: it owns the // wrapper, so there is nothing to inherit and its own // preRenderContainer is entirely legitimate. (libEmbed as any).connectPreRendered(); expect((libEmbed as any).preRenderContainerEl).toBe(customContainer); expect(warnSpy).not.toHaveBeenCalledWith( expect.stringContaining('preRenderContainer is applied only by'), ); warnSpy.mockRestore(); libEmbed.destroy(); customContainer.remove(); }); it('should stay on document.body when the wrapper has no owning embed', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'connect-unstamped-container'; document.body.appendChild(customContainer); const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); const firstEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-unstamped', liveboardId: 'myLiveboardId', preRenderContainer: customContainer, }); await firstEmbed.preRender(); await waitFor(() => !!getIFrameEl()); // Simulate a wrapper with no live embed behind it: the // __tsEmbed stamp used to locate the owner is gone, so there // is no container to inherit. const wrapper = document.getElementById( firstEmbed.getPreRenderIds().wrapper, ); delete (wrapper as any).__tsEmbed; const connectingEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'connect-unstamped', liveboardId: 'myLiveboardId', }); (connectingEmbed as any).connectPreRendered(); // Falls back to the sentinel rather than crashing on the // missing owner, and warns about nothing. expect((connectingEmbed as any).preRenderContainerEl).toBe(document.body); expect(warnSpy).not.toHaveBeenCalledWith( expect.stringContaining('preRenderContainer is applied only by'), ); warnSpy.mockRestore(); firstEmbed.destroy(); customContainer.remove(); }); }); }); describe('preRenderConfig', () => { beforeEach(() => { (window as any).ResizeObserver = jest.fn().mockImplementation(() => ({ disconnect: jest.fn(), observe: jest.fn(), unobserve: jest.fn(), })); }); it('should use default z-index of -1000 when preRenderConfig is not set', async () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'pre-render-config-defaults', liveboardId: 'myLiveboardId', }); await libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); expect(preRenderWrapper.style.zIndex).toBe('-1000'); libEmbed.destroy(); }); it('should apply custom zIndex from preRenderConfig when hidden', async () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'pre-render-config-custom-z-index', liveboardId: 'myLiveboardId', preRenderConfig: { zIndex: -10, }, }); await libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); expect(preRenderWrapper.style.zIndex).toBe('-10'); libEmbed.destroy(); }); it('should clear z-index on showPreRender and restore custom zIndex on hidePreRender', async () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'pre-render-config-cycle', liveboardId: 'myLiveboardId', preRenderConfig: { zIndex: -5, }, }); await libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); expect(preRenderWrapper.style.zIndex).toBe('-5'); libEmbed.showPreRender(); expect(preRenderWrapper.style.zIndex).toBe(''); libEmbed.hidePreRender(); expect(preRenderWrapper.style.zIndex).toBe('-5'); libEmbed.destroy(); }); it('should use preRenderConfig.id to identify the pre-render instance', async () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', preRenderConfig: { id: 'config-level-id', }, }); await libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const preRenderIds = libEmbed.getPreRenderIds(); expect(preRenderIds.wrapper).toContain('config-level-id'); expect(document.getElementById(preRenderIds.wrapper)).not.toBeNull(); libEmbed.destroy(); }); it('should prefer preRenderConfig.id over top-level preRenderId', async () => { createRootEleForEmbed(); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', preRenderId: 'top-level-id', preRenderConfig: { id: 'config-level-id', }, }); await libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const preRenderIds = libEmbed.getPreRenderIds(); expect(preRenderIds.wrapper).toContain('config-level-id'); expect(preRenderIds.wrapper).not.toContain('top-level-id'); libEmbed.destroy(); }); it('should mount the wrapper into preRenderConfig.containerSelector', async () => { createRootEleForEmbed(); const customContainer = document.createElement('div'); customContainer.id = 'config-level-container'; document.body.appendChild(customContainer); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', preRenderConfig: { id: 'container-via-config', containerSelector: '#config-level-container', }, }); await libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); expect(customContainer.contains(preRenderWrapper)).toBe(true); libEmbed.destroy(); customContainer.remove(); }); it('should prefer preRenderConfig.containerSelector over top-level preRenderContainer', async () => { createRootEleForEmbed(); const topContainer = document.createElement('div'); topContainer.id = 'top-level-container'; document.body.appendChild(topContainer); const configContainer = document.createElement('div'); configContainer.id = 'config-level-container-2'; document.body.appendChild(configContainer); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', preRenderContainer: '#top-level-container', preRenderConfig: { id: 'container-precedence', containerSelector: '#config-level-container-2', }, }); await libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); const preRenderIds = libEmbed.getPreRenderIds(); const preRenderWrapper = document.getElementById(preRenderIds.wrapper); expect(configContainer.contains(preRenderWrapper)).toBe(true); expect(topContainer.contains(preRenderWrapper)).toBe(false); libEmbed.destroy(); topContainer.remove(); configContainer.remove(); }); it('should not attach ResizeObserver when preRenderConfig.doNotTrackSize is true', async () => { createRootEleForEmbed(); const observeMock = jest.fn(); (window as any).ResizeObserver = jest.fn().mockImplementation(() => ({ disconnect: jest.fn(), observe: observeMock, unobserve: jest.fn(), })); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', preRenderConfig: { id: 'no-track-via-config', doNotTrackSize: true, }, }); await libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); libEmbed.showPreRender(); expect(observeMock).not.toHaveBeenCalled(); libEmbed.destroy(); }); it('should prefer preRenderConfig.doNotTrackSize over top-level doNotTrackPreRenderSize', async () => { createRootEleForEmbed(); const observeMock = jest.fn(); (window as any).ResizeObserver = jest.fn().mockImplementation(() => ({ disconnect: jest.fn(), observe: observeMock, unobserve: jest.fn(), })); const libEmbed = new LiveboardEmbed('#tsEmbedDiv', { liveboardId: 'myLiveboardId', doNotTrackPreRenderSize: false, preRenderConfig: { id: 'no-track-precedence', doNotTrackSize: true, }, }); await libEmbed.preRender(); await waitFor(() => !!getIFrameEl()); libEmbed.showPreRender(); expect(observeMock).not.toHaveBeenCalled(); libEmbed.destroy(); }); }); }); describe('IdleSessionTimeout embedEvent for TrustedAuthTokenCookieless authType with autoLogin true', () => { beforeAll(() => { jest.spyOn(authInstance, 'doCookielessTokenAuth').mockResolvedValueOnce(true); jest.spyOn(authService, 'verifyTokenService').mockResolvedValueOnce(true); init({ thoughtSpotHost: 'tshost', customizations: customisations, authType: AuthType.TrustedAuthTokenCookieless, getAuthToken: () => Promise.resolve('test_auth_token2'), autoLogin: true, }); }); test('should handle idle session timeout and send updated auth token', async () => { const mockEmbedEventPayload = { type: EmbedEvent.IdleSessionTimeout, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); jest.spyOn(baseInstance, 'handleAuth'); jest.spyOn(baseInstance, 'notifyAuthFailure'); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(baseInstance.notifyAuthFailure).toHaveBeenCalledWith( authInstance.AuthFailureType.IDLE_SESSION_TIMEOUT, ); expect(baseInstance.handleAuth).toHaveBeenCalled(); expect(mockPort.postMessage).toHaveBeenCalledWith({ type: EmbedEvent.IdleSessionTimeout, data: { authToken: 'test_auth_token2' }, }); }); }); test('should handle idle session timeout and show login failure message if token fetch fails', async () => { init({ thoughtSpotHost: 'tshost', customizations: customisations, authType: AuthType.TrustedAuthTokenCookieless, getAuthToken: () => Promise.reject(), autoLogin: true, }); const mockEmbedEventPayload = { type: EmbedEvent.IdleSessionTimeout, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); jest.spyOn(baseInstance, 'notifyAuthFailure'); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; const loggerSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}); await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(getRootEl().innerHTML).toContain('Not logged in'); expect(baseInstance.notifyAuthFailure).toHaveBeenCalledWith( authInstance.AuthFailureType.IDLE_SESSION_TIMEOUT, ); expect(loggerSpy).toHaveBeenCalledTimes(1); }); jest.spyOn(authService, 'verifyTokenService').mockClear(); jest.spyOn(baseInstance, 'notifyAuthFailure').mockClear(); }); test('should handle idle session timeout and show login failure message if handleAuth fails', async () => { init({ thoughtSpotHost: 'tshost', customizations: customisations, authType: AuthType.TrustedAuthTokenCookieless, getAuthToken: () => Promise.resolve('test_auth_token2'), autoLogin: true, }); const mockEmbedEventPayload = { type: EmbedEvent.IdleSessionTimeout, data: {}, }; const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); jest.spyOn(baseInstance, 'notifyAuthFailure'); jest.spyOn(authInstance, 'authenticate').mockResolvedValue(false); searchEmbed.render(); const mockPort: any = { postMessage: jest.fn(), }; const loggerSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}); await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); await executeAfterWait(() => { expect(baseInstance.notifyAuthFailure).toHaveBeenCalledWith( authInstance.AuthFailureType.IDLE_SESSION_TIMEOUT, ); expect(loggerSpy).toHaveBeenCalledTimes(0); }); jest.spyOn(authService, 'verifyTokenService').mockClear(); jest.spyOn(baseInstance, 'notifyAuthFailure').mockClear(); }); }); describe('Renders should wait for init to completed', () => { const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}); beforeEach(() => { errorSpy.mockClear(); resetValueFromWindow('initFlagKey'); baseInstance.createAndSetInitPromise(); document.body.innerHTML = getDocumentBody(); }); test('Pre-render should wait for init to complete', async () => { const lib = new LiveboardEmbed(getRootEl(), { preRenderId: 'test', liveboardId: 'test', }); lib.preRender(); await executeAfterWait(() => { expect(errorSpy).toHaveBeenCalledWith(ERROR_MESSAGE.RENDER_CALLED_BEFORE_INIT); expect(getRootEl().innerHTML).toContain(''); }); const iframeBeforeInit = getIFrameEl(); expect(iframeBeforeInit).toBe(null); init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); await waitFor(() => !!getIFrameEl()); const preRenderId = lib.getPreRenderIds().wrapper; expect(document.getElementById(preRenderId)).not.toBe(null); const iframeAfterInit = getIFrameEl(); expect(iframeAfterInit).not.toBe(null); }); test('Render should wait for init to complete', async () => { const lib = new LiveboardEmbed(getRootEl(), { liveboardId: 'test' }); lib.render(); await executeAfterWait(() => { expect(errorSpy).toHaveBeenCalledWith(ERROR_MESSAGE.RENDER_CALLED_BEFORE_INIT); expect(getRootEl().innerHTML).toContain(''); }); const iframeBeforeInit = getIFrameEl(); expect(iframeBeforeInit).toBe(null); init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); await waitFor(() => !!getIFrameEl()); expect(getRootEl()).not.toBe(null); const iframeAfterInit = getIFrameEl(); expect(iframeAfterInit).not.toBe(null); }); test('Pre Render Generic should wait for init to complete', async () => { const lib = new LiveboardEmbed(getRootEl(), {}); lib.prerenderGeneric(); await executeAfterWait(() => { expect(errorSpy).toHaveBeenCalledWith(ERROR_MESSAGE.RENDER_CALLED_BEFORE_INIT); expect(getRootEl().innerHTML).toContain(''); }); const iframeBeforeInit = getIFrameEl(); expect(iframeBeforeInit).toBe(null); init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); await waitFor(() => !!getIFrameEl()); expect(getRootEl()).not.toBe(null); const iframeAfterInit = getIFrameEl(); expect(iframeAfterInit).not.toBe(null); }); }); describe('AutoLogin behavior in updateAuthToken', () => { const mockPort = { postMessage: jest.fn() }; const mockEmbedEventPayload = { type: EmbedEvent.AuthExpire, data: {} }; beforeEach(() => { jest.clearAllMocks(); document.body.innerHTML = getDocumentBody(); mockPort.postMessage.mockClear(); jest.spyOn(authToken, 'getAuthenticationToken').mockResolvedValue( 'mock-test-token-placeholder', ); jest.spyOn(baseInstance, 'handleAuth').mockImplementation(() => Promise.resolve(true)); jest.spyOn(baseInstance, 'notifyAuthFailure').mockImplementation(() => {}); }); const renderAndTriggerAuthExpire = async () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); await searchEmbed.render(); await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); }; test('Cookieless with autoLogin undefined should default to true', async () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.TrustedAuthTokenCookieless, // autoLogin undefined }); await renderAndTriggerAuthExpire(); await executeAfterWait(() => { expect(authToken.getAuthenticationToken).toHaveBeenCalled(); expect(baseInstance.handleAuth).toHaveBeenCalledTimes(1); expect(mockPort.postMessage).toHaveBeenCalledWith({ type: EmbedEvent.AuthExpire, data: { authToken: 'mock-test-token-placeholder' }, }); }); }); test('Cookieless with autoLogin false should not get auth token', async () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.TrustedAuthTokenCookieless, autoLogin: false, }); await renderAndTriggerAuthExpire(); await executeAfterWait(() => { expect(authToken.getAuthenticationToken).not.toHaveBeenCalled(); expect(baseInstance.handleAuth).toHaveBeenCalledTimes(1); expect(mockPort.postMessage).not.toHaveBeenCalled(); }); }); test('Cookieless with autoLogin true should get auth token', async () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.TrustedAuthTokenCookieless, autoLogin: true, }); await renderAndTriggerAuthExpire(); await executeAfterWait(() => { expect(authToken.getAuthenticationToken).toHaveBeenCalled(); expect(baseInstance.handleAuth).toHaveBeenCalledTimes(1); expect(mockPort.postMessage).toHaveBeenCalledWith({ type: EmbedEvent.AuthExpire, data: { authToken: 'mock-test-token-placeholder' }, }); }); }); test('Other authType with autoLogin undefined should default to false', async () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, // autoLogin undefined }); await renderAndTriggerAuthExpire(); await executeAfterWait(() => { expect(authToken.getAuthenticationToken).not.toHaveBeenCalled(); expect(baseInstance.handleAuth).toHaveBeenCalledTimes(1); }); }); test('Other authType with autoLogin true should call handleAuth', async () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, autoLogin: true, }); await renderAndTriggerAuthExpire(); await executeAfterWait(() => { expect(authToken.getAuthenticationToken).not.toHaveBeenCalled(); expect(baseInstance.handleAuth).toHaveBeenCalledTimes(2); }); }); test('Other authType with autoLogin false should not call handleAuth', async () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, autoLogin: false, }); await renderAndTriggerAuthExpire(); await executeAfterWait(() => { expect(authToken.getAuthenticationToken).not.toHaveBeenCalled(); expect(baseInstance.handleAuth).toHaveBeenCalledTimes(1); }); }); afterEach(() => { expect(baseInstance.notifyAuthFailure).toHaveBeenCalledWith( authInstance.AuthFailureType.EXPIRY, ); }); }); describe('AutoLogin behavior in tokenRefresh', () => { const mockPort = { postMessage: jest.fn() }; const mockEmbedEventPayload = { type: EmbedEvent.RefreshAuthToken, data: {} }; beforeEach(() => { jest.clearAllMocks(); document.body.innerHTML = getDocumentBody(); mockPort.postMessage.mockClear(); jest.spyOn(authToken, 'getAuthenticationToken').mockResolvedValue( 'mock-test-token-placeholder', ); jest.spyOn(processData, 'processAuthFailure').mockImplementation(() => ({}) as any); jest.spyOn(logger, 'error').mockImplementation(() => {}); }); const renderAndTriggerRefreshAuthToken = async () => { const spotterEmbed = new SpotterEmbed(getRootEl(), { worksheetId: 'test-worksheet', searchOptions: { searchQuery: 'test query', }, } as SpotterEmbedViewConfig); await spotterEmbed.render(); await executeAfterWait(() => { const iframe = getIFrameEl(); postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); }); }; test('Cookieless with autoLogin undefined should default to true and refresh token', async () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.TrustedAuthTokenCookieless, // autoLogin undefined }); await renderAndTriggerRefreshAuthToken(); await executeAfterWait(() => { expect(authToken.getAuthenticationToken).toHaveBeenCalledWith( expect.any(Object), true, ); }); }); test('Cookieless with autoLogin true should refresh token', async () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.TrustedAuthTokenCookieless, autoLogin: true, }); await renderAndTriggerRefreshAuthToken(); await executeAfterWait(() => { expect(authToken.getAuthenticationToken).toHaveBeenCalledWith( expect.any(Object), true, ); }); }); test('Cookieless with autoLogin false should not refresh token', async () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.TrustedAuthTokenCookieless, autoLogin: false, }); await renderAndTriggerRefreshAuthToken(); await executeAfterWait(() => { expect(authToken.getAuthenticationToken).not.toHaveBeenCalled(); expect(mockPort.postMessage).not.toHaveBeenCalled(); }); }); test('Should handle error when getAuthenticationToken fails', async () => { const error = new Error('Token fetch failed'); jest.spyOn(authToken, 'getAuthenticationToken').mockRejectedValue(error); init({ thoughtSpotHost: 'tshost', authType: AuthType.TrustedAuthTokenCookieless, autoLogin: true, }); await renderAndTriggerRefreshAuthToken(); await executeAfterWait(() => { expect(authToken.getAuthenticationToken).toHaveBeenCalledWith( expect.any(Object), true, ); // Check that logger.error was called with the token refresh // error const errorCalls = (logger.error as jest.Mock).mock.calls.filter( (call) => call[0]?.includes(ERROR_MESSAGE.INVALID_TOKEN_ERROR) && call[0]?.includes('Token fetch failed'), ); expect(errorCalls.length).toBeGreaterThan(0); expect(processData.processAuthFailure).toHaveBeenCalledWith( error, expect.any(Element), ); expect(mockPort.postMessage).not.toHaveBeenCalled(); }); }); }); describe('Fullscreen Change Handler', () => { beforeEach(() => { document.body.innerHTML = getDocumentBody(); init({ thoughtSpotHost: 'tshost', authType: AuthType.None, disableFullscreenPresentation: false, }); }); test('should have setupFullscreenChangeHandler method', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); expect(typeof searchEmbed['setupFullscreenChangeHandler']).toBe('function'); }); test('should have removeFullscreenChangeHandler method', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); expect(typeof searchEmbed['removeFullscreenChangeHandler']).toBe('function'); }); test('should call setupFullscreenChangeHandler without errors', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); expect(() => { searchEmbed['setupFullscreenChangeHandler'](); }).not.toThrow(); }); test('should call removeFullscreenChangeHandler without errors', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); expect(() => { searchEmbed['removeFullscreenChangeHandler'](); }).not.toThrow(); }); test('should handle fullscreen change when feature flag is disabled', () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, disableFullscreenPresentation: true, }); const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); expect(() => { searchEmbed['setupFullscreenChangeHandler'](); }).not.toThrow(); }); }); describe('Embed Container Loading', () => { beforeEach(() => { document.body.innerHTML = getDocumentBody(); init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); jest.spyOn(sessionInfoService, 'getSessionInfo').mockResolvedValue({ releaseVersion: '1.0.0', userGUID: '1234567890', currentOrgId: 1, privileges: [], mixpanelToken: '1234567890', isPublicUser: false, clusterId: 'cluster1', clusterName: 'Test Cluster', }); }); test('should initialize with isEmbedContainerLoaded as false', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); expect(searchEmbed.isEmbedContainerLoaded).toBe(false); }); test('should have empty embedContainerReadyCallbacks array initially', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); expect(searchEmbed['embedContainerReadyCallbacks']).toEqual([]); }); test('should execute callback immediately if embed container is already loaded', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.isEmbedContainerLoaded = true; const callback = jest.fn(); searchEmbed['executeAfterEmbedContainerLoaded'](callback); expect(callback).toHaveBeenCalledTimes(1); }); test('should queue callback if embed container is not loaded', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.isEmbedContainerLoaded = false; const callback = jest.fn(); searchEmbed['executeAfterEmbedContainerLoaded'](callback); expect(callback).not.toHaveBeenCalled(); expect(searchEmbed['embedContainerReadyCallbacks']).toContain(callback); }); test('should execute all queued callbacks when embed container becomes ready', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.isEmbedContainerLoaded = false; const callback1 = jest.fn(); const callback2 = jest.fn(); const callback3 = jest.fn(); searchEmbed['executeAfterEmbedContainerLoaded'](callback1); searchEmbed['executeAfterEmbedContainerLoaded'](callback2); searchEmbed['executeAfterEmbedContainerLoaded'](callback3); expect(callback1).not.toHaveBeenCalled(); expect(callback2).not.toHaveBeenCalled(); expect(callback3).not.toHaveBeenCalled(); // Simulate embed container becoming ready searchEmbed['executeEmbedContainerReadyCallbacks'](); expect(callback1).toHaveBeenCalledTimes(1); expect(callback2).toHaveBeenCalledTimes(1); expect(callback3).toHaveBeenCalledTimes(1); }); test('should handle AuthInit event and set embed container as loaded after timeout', async () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.render(); await executeAfterWait(() => { const iframe = getIFrameEl(); expect(iframe).toBeTruthy(); }); const iframe = getIFrameEl(); const callback = jest.fn(); searchEmbed['executeAfterEmbedContainerLoaded'](callback); // Simulate AuthInit event postMessageToParent(iframe.contentWindow, { type: EmbedEvent.AuthInit, }); expect(callback).not.toHaveBeenCalled(); expect(searchEmbed.isEmbedContainerLoaded).toBe(false); // Wait for the 1-second timeout await executeAfterWait(() => { expect(searchEmbed.isEmbedContainerLoaded).toBe(true); expect(callback).toHaveBeenCalledTimes(1); }, 1100); }); test('should handle EmbedListenerReady event and set embed container as loaded immediately', async () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.render(); await executeAfterWait(() => { const iframe = getIFrameEl(); expect(iframe).toBeTruthy(); }); const iframe = getIFrameEl(); const callback = jest.fn(); searchEmbed['executeAfterEmbedContainerLoaded'](callback); // Simulate EmbedListenerReady event postMessageToParent(iframe.contentWindow, { type: EmbedEvent.EmbedListenerReady, }); await executeAfterWait(() => { expect(searchEmbed.isEmbedContainerLoaded).toBe(true); expect(callback).toHaveBeenCalledTimes(1); }); }); test('should check prerendered object for embed container loaded state', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); // Mock a prerendered object with loaded state const mockPreRenderObj = { isEmbedContainerLoaded: true, }; jest.spyOn(searchEmbed as any, 'getPreRenderObj').mockReturnValue( mockPreRenderObj as any, ); const result = searchEmbed['checkEmbedContainerLoaded'](); expect(result).toBe(true); expect(searchEmbed.isEmbedContainerLoaded).toBe(true); }); test('should return getPreRenderObj and log if same object', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); // debug, not info: the wrapper points at the embed being shown, so // this is now the common case rather than a rare diagnostic. const loggerSpy = jest.spyOn(logger, 'debug'); // getPreRenderObj reads the embed reference from preRenderWrapper (searchEmbed as any).preRenderWrapper = { [searchEmbed['embedNodeKey']]: searchEmbed, }; const result = searchEmbed['getPreRenderObj'](); expect(result).toBe(searchEmbed); expect(loggerSpy).toHaveBeenCalledWith('embedObj is same as this'); }); test('should handle null/undefined callbacks gracefully', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); expect(() => { searchEmbed['executeAfterEmbedContainerLoaded'](null); searchEmbed['executeAfterEmbedContainerLoaded'](undefined); }).not.toThrow(); }); test('should handle multiple callback executions correctly', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); const callback1 = jest.fn(); const callback2 = jest.fn(); // Add callbacks when container is not loaded searchEmbed['executeAfterEmbedContainerLoaded'](callback1); searchEmbed['executeAfterEmbedContainerLoaded'](callback2); // Execute callbacks searchEmbed['executeEmbedContainerReadyCallbacks'](); expect(callback1).toHaveBeenCalledTimes(1); expect(callback2).toHaveBeenCalledTimes(1); // Add another callback after container is loaded searchEmbed.isEmbedContainerLoaded = true; const callback3 = jest.fn(); searchEmbed['executeAfterEmbedContainerLoaded'](callback3); expect(callback3).toHaveBeenCalledTimes(1); }); describe('getCurrentContext', () => { const mockContext: ContextObject = { stack: [ { name: 'Liveboard', type: 'Liveboard' as any, objectIds: { liveboardId: 'lb-123' }, }, ], currentContext: { name: 'Liveboard', type: 'Liveboard' as any, objectIds: { liveboardId: 'lb-123' }, }, }; test('should return context when embed container is already loaded', async () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.isEmbedContainerLoaded = true; const triggerSpy = jest .spyOn(searchEmbed, 'trigger') .mockResolvedValue(mockContext); const context = await searchEmbed.getCurrentContext(); expect(context).toEqual(mockContext); expect(triggerSpy).toHaveBeenCalledWith(HostEvent.GetPageContext, {}); }); test('should wait for embed container to load before returning context', async () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); searchEmbed.isEmbedContainerLoaded = false; const triggerSpy = jest .spyOn(searchEmbed, 'trigger') .mockResolvedValue(mockContext); const contextPromise = searchEmbed.getCurrentContext(); // Context should not be resolved yet await executeAfterWait(() => { expect(triggerSpy).not.toHaveBeenCalled(); }, 10); // Simulate embed container becoming ready searchEmbed['executeEmbedContainerReadyCallbacks'](); const context = await contextPromise; expect(context).toEqual(mockContext); expect(triggerSpy).toHaveBeenCalledWith(HostEvent.GetPageContext, {}); }); }); test('should register embed container event handlers during construction', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); // Check that the event handlers are registered const eventHandlerMap = searchEmbed['eventHandlerMap']; expect(eventHandlerMap.has(EmbedEvent.AuthInit)).toBe(true); expect(eventHandlerMap.has(EmbedEvent.EmbedListenerReady)).toBe(true); }); test('should handle handleEmbedContainerLoaded with AuthInit source', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); jest.useFakeTimers(); const handler = searchEmbed['createEmbedContainerHandler'](EmbedEvent.AuthInit); expect(searchEmbed.isEmbedContainerLoaded).toBe(false); handler(); expect(searchEmbed.isEmbedContainerLoaded).toBe(false); // Fast-forward time jest.advanceTimersByTime(1000); expect(searchEmbed.isEmbedContainerLoaded).toBe(true); jest.useRealTimers(); }); test('should handle handleEmbedContainerLoaded with EmbedListenerReady source', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); const handler = searchEmbed['createEmbedContainerHandler']( EmbedEvent.EmbedListenerReady, ); expect(searchEmbed.isEmbedContainerLoaded).toBe(false); handler(); expect(searchEmbed.isEmbedContainerLoaded).toBe(true); }); }); describe('Online event listener registration after auth failure', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, loginFailedMessage: 'Not logged in', }); }); test('should register online event listener when authentication fails', async () => { const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); jest.spyOn(baseInstance, 'getAuthPromise').mockRejectedValueOnce( new Error('Auth failed'), ); const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); addEventListenerSpy.mockClear(); await searchEmbed.render(); await executeAfterWait(() => { expect(getRootEl().innerHTML).toContain('Not logged in'); const onlineListenerCalls = addEventListenerSpy.mock.calls.filter( (call) => call[0] === 'online', ); expect(onlineListenerCalls).toHaveLength(1); const offlineListenerCalls = addEventListenerSpy.mock.calls.filter( (call) => call[0] === 'offline', ); expect(offlineListenerCalls).toHaveLength(1); const messageListenerCalls = addEventListenerSpy.mock.calls.filter( (call) => call[0] === 'message', ); expect(messageListenerCalls).toHaveLength(0); }); addEventListenerSpy.mockRestore(); }); test('should attempt to trigger reload when online event occurs after auth failure', async () => { jest.spyOn(baseInstance, 'getAuthPromise').mockRejectedValueOnce( new Error('Auth failed'), ); const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); const triggerSpy = jest.spyOn(searchEmbed, 'trigger').mockResolvedValue(null); await searchEmbed.render(); await executeAfterWait(() => { expect(getRootEl().innerHTML).toContain('Not logged in'); triggerSpy.mockClear(); const onlineEvent = new Event('online'); window.dispatchEvent(onlineEvent); expect(triggerSpy).toHaveBeenCalledWith(HostEvent.Reload); }); triggerSpy.mockReset(); }); test('should handle online event gracefully when no iframe exists', async () => { jest.spyOn(baseInstance, 'getAuthPromise').mockRejectedValueOnce( new Error('Auth failed'), ); const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); await searchEmbed.render(); await executeAfterWait(() => { expect(getRootEl().innerHTML).toContain('Not logged in'); const onlineEvent = new Event('online'); expect(() => { window.dispatchEvent(onlineEvent); }).not.toThrow(); }); errorSpy.mockReset(); }); test('should register all event listeners when authentication succeeds', async () => { const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); jest.spyOn(baseInstance, 'getAuthPromise').mockResolvedValueOnce(true); const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); addEventListenerSpy.mockClear(); await searchEmbed.render(); await executeAfterWait(() => { const onlineListenerCalls = addEventListenerSpy.mock.calls.filter( (call) => call[0] === 'online', ); expect(onlineListenerCalls).toHaveLength(1); const offlineListenerCalls = addEventListenerSpy.mock.calls.filter( (call) => call[0] === 'offline', ); expect(offlineListenerCalls).toHaveLength(1); const messageListenerCalls = addEventListenerSpy.mock.calls.filter( (call) => call[0] === 'message', ); expect(messageListenerCalls).toHaveLength(1); }); addEventListenerSpy.mockRestore(); }); test('should successfully trigger reload when online event occurs after auth success', async () => { jest.spyOn(baseInstance, 'getAuthPromise').mockResolvedValueOnce(true); const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); const triggerSpy = jest.spyOn(searchEmbed, 'trigger').mockResolvedValue({} as any); await searchEmbed.render(); await executeAfterWait(() => { triggerSpy.mockClear(); const onlineEvent = new Event('online'); window.dispatchEvent(onlineEvent); expect(triggerSpy).toHaveBeenCalledWith(HostEvent.Reload); }); triggerSpy.mockReset(); }); }); describe('When destroyed', () => { it('should remove the iframe', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); await appEmbed.render(); expect(getIFrameEl()).toBeTruthy(); appEmbed.destroy(); expect(getIFrameEl()).toBeFalsy(); }); it('should remove the iframe when insertAsSibling is true', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, insertAsSibling: true, }); await appEmbed.render(); expect(getIFrameEl()).toBeTruthy(); appEmbed.destroy(); expect(getIFrameEl()).toBeFalsy(); }); it("Should remove the error message on destroy if it's present", async () => { jest.spyOn(baseInstance, 'getAuthPromise').mockResolvedValueOnce(false); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, insertAsSibling: true, }); await appEmbed.render(); expect(getRootEl().nextElementSibling.innerHTML).toContain('Not logged in'); appEmbed.destroy(); expect(getRootEl().nextElementSibling.innerHTML).toBe(''); }); it('should not call trigger or remove DOM if destroy is called before render', () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%' }, }); const triggerSpy = jest.spyOn(appEmbed, 'trigger'); const removeChildSpy = jest.spyOn(Node.prototype, 'removeChild'); appEmbed.destroy(); expect(triggerSpy).not.toHaveBeenCalled(); expect(removeChildSpy).not.toHaveBeenCalled(); }); it('should still remove DOM element when trigger rejects (waitForCleanupOnDestroy: true)', async () => { const originalEmbedConfig = embedConfig.getEmbedConfig(); embedConfig.setEmbedConfig({ ...originalEmbedConfig, waitForCleanupOnDestroy: true, cleanupTimeout: 1000, }); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%' }, }); await appEmbed.render(); jest.spyOn(appEmbed, 'trigger').mockRejectedValue(new Error('trigger failed')); const removeChildSpy = jest .spyOn(Node.prototype, 'removeChild') .mockImplementation(() => getRootEl()); appEmbed.destroy(); await new Promise((resolve) => setTimeout(resolve, 50)); expect(removeChildSpy).toHaveBeenCalled(); embedConfig.setEmbedConfig(originalEmbedConfig); }); describe('with waitForCleanupOnDestroy configuration', () => { let originalEmbedConfig: any; beforeEach(() => { originalEmbedConfig = embedConfig.getEmbedConfig(); }); afterEach(() => { embedConfig.setEmbedConfig(originalEmbedConfig); }); it('should trigger DestroyEmbed event immediately when waitForCleanupOnDestroy is false', async () => { embedConfig.setEmbedConfig({ ...originalEmbedConfig, waitForCleanupOnDestroy: false, }); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); await appEmbed.render(); const triggerSpy = jest.spyOn(appEmbed, 'trigger').mockResolvedValue(null); const removeChildSpy = jest .spyOn(Node.prototype, 'removeChild') .mockImplementation(() => getRootEl()); appEmbed.destroy(); expect(triggerSpy).toHaveBeenCalledWith(HostEvent.DestroyEmbed); expect(removeChildSpy).toHaveBeenCalled(); }); it('should trigger DestroyEmbed event and wait for cleanup when waitForCleanupOnDestroy is true', async () => { embedConfig.setEmbedConfig({ ...originalEmbedConfig, waitForCleanupOnDestroy: true, cleanupTimeout: 1000, }); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); await appEmbed.render(); const triggerSpy = jest.spyOn(appEmbed, 'trigger').mockResolvedValue(null); const removeChildSpy = jest .spyOn(Node.prototype, 'removeChild') .mockImplementation(() => getRootEl()); appEmbed.destroy(); // 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)); expect(removeChildSpy).toHaveBeenCalled(); }); it('should handle Promise.race with successful cleanup completion', async () => { embedConfig.setEmbedConfig({ ...originalEmbedConfig, waitForCleanupOnDestroy: true, cleanupTimeout: 2000, }); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); await appEmbed.render(); // Mock trigger to resolve quickly (before timeout) const triggerSpy = jest .spyOn(appEmbed, 'trigger') .mockImplementation( () => new Promise((resolve) => setTimeout(() => resolve(null), 100)), ); const removeChildSpy = jest .spyOn(Node.prototype, 'removeChild') .mockImplementation(() => getRootEl()); appEmbed.destroy(); // Wait for the trigger to complete await new Promise((resolve) => setTimeout(resolve, 200)); expect(triggerSpy).toHaveBeenCalledWith(HostEvent.DestroyEmbed); expect(removeChildSpy).toHaveBeenCalled(); }); it('should handle Promise.race with timeout when cleanup takes too long', async () => { embedConfig.setEmbedConfig({ ...originalEmbedConfig, waitForCleanupOnDestroy: true, cleanupTimeout: 100, }); const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%', }, }); await appEmbed.render(); // Mock trigger to take longer than timeout const triggerSpy = jest .spyOn(appEmbed, 'trigger') .mockImplementation( () => new Promise((resolve) => setTimeout(() => resolve(null), 500)), ); const removeChildSpy = jest .spyOn(Node.prototype, 'removeChild') .mockImplementation(() => getRootEl()); appEmbed.destroy(); // Wait for the timeout to complete await new Promise((resolve) => setTimeout(resolve, 200)); expect(triggerSpy).toHaveBeenCalledWith(HostEvent.DestroyEmbed); expect(removeChildSpy).toHaveBeenCalled(); }); }); }); 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); 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); }); }); test('should execute callbacks through executeEvent function', async () => { let capturedExecuteEvent: any; mockHandleInterceptEvent.mockImplementation(async (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(async (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(); // 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 expect(callArgs[1]).toBe('UiPassthrough'); expect(callArgs[3]).toMatchObject({ type: 'getUnsavedAnswerTML', parameters: { 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(async (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(async (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, }), ); }); }); }); }); describe('Additional Coverage Tests', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); }); test('should handle getAuthTokenForCookielessInit with non-cookieless auth', async () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); const token = await searchEmbed['getAuthTokenForCookielessInit'](); expect(token).toBe(''); }); test('should call setIFrameHeight', async () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); await searchEmbed.render(); await executeAfterWait(() => { searchEmbed['setIFrameHeight'](500); expect(getIFrameEl().style.height).toBe('500px'); }); }); test('should handle preRender with replaceExistingPreRender=true', async () => { createRootEleForEmbed(); const embed1 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'test-replace', liveboardId: 'lb1', }); await embed1.preRender(); const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'test-replace', liveboardId: 'lb2', }); await embed2.preRender(false, true); expect(document.getElementById('tsEmbed-pre-render-wrapper-test-replace')).toBeTruthy(); }); test('should test getIframeSrc base implementation', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); expect(searchEmbed.getIframeSrc()).toBe(''); }); test('should handle createEmbedEventResponder with OnBeforeGetVizDataIntercept', async () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); const mockPort: any = { postMessage: jest.fn() }; const responder = searchEmbed['createEmbedEventResponder']( mockPort, EmbedEvent.OnBeforeGetVizDataIntercept, ); responder({ data: 'test' }); expect(mockPort.postMessage).toHaveBeenCalled(); }); test('should clean up message event listeners', async () => { const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); await searchEmbed.render(); searchEmbed['unsubscribeToMessageEvents'](); expect(removeEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); }); }); describe('Trigger method edge cases', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); }); beforeEach(() => { document.body.innerHTML = getDocumentBody(); }); test('should handle error when trigger is called with undefined messageType', async () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); jest.spyOn(logger, 'error'); await searchEmbed.render(); await executeAfterWait(async () => { const result = await searchEmbed.trigger(undefined as any); expect(result).toBeNull(); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, code: EmbedErrorCodes.HOST_EVENT_TYPE_UNDEFINED, }), ); }); }); test('should return null when trigger is called before iframe is ready', async () => { jest.spyOn(baseInstance, 'getAuthPromise').mockRejectedValueOnce(new Error('Auth failed')); const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); jest.spyOn(logger, 'debug'); await searchEmbed.render(); await executeAfterWait(async () => { const result = await searchEmbed.trigger(HostEvent.Reload); expect(result).toBeNull(); }); }); test('isValidationError catch: handleError called with embedErrorDetails and error is rethrown', async () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); const handleErrorSpy = jest.spyOn(searchEmbed as any, 'handleError').mockImplementation(() => {}); await searchEmbed.render(); // First processTrigger call is getAvailableUIPassthroughKeys for DrillDown mockProcessTrigger.mockResolvedValueOnce([ { value: { keys: Object.values(UIPassthroughEvent) } }, ]); // DrillDown with no points → throwDrillDownValidationError → isValidationError=true await expect( searchEmbed.trigger(HostEvent.DrillDown, {} as any), ).rejects.toThrow('DrillDown requires a valid points object'); expect(handleErrorSpy).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ code: EmbedErrorCodes.HOST_EVENT_VALIDATION }), }), ); }); }); describe('PreRender replaceExistingPreRender scenarios', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); }); beforeEach(() => { document.body.innerHTML = getDocumentBody(); }); afterEach(() => { const rootEle = document.getElementById('myRoot'); rootEle?.remove(); }); test('should skip re-rendering when preRender already exists and replaceExistingPreRender is false', async () => { createRootEleForEmbed(); const embed1 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'no-replace-test', liveboardId: 'lb1', }); await embed1.preRender(); await waitFor(() => !!getIFrameEl()); const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'no-replace-test', liveboardId: 'lb2', }); const result = await embed2.preRender(false, false); expect(result).toBe(embed2); // The original iframe should still have lb1 const iframe = getIFrameEl(); expect(iframe.src).toContain('lb1'); }); }); describe('Destroy error handling', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); }); beforeEach(() => { document.body.innerHTML = getDocumentBody(); }); test('should handle error gracefully when destroy fails', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%' }, }); await appEmbed.render(); const logSpy = jest.spyOn(logger, 'log').mockImplementation(() => {}); jest.spyOn(Node.prototype, 'removeChild').mockImplementationOnce(() => { throw new Error('Remove failed'); }); expect(() => { appEmbed.destroy(); }).not.toThrow(); expect(logSpy).toHaveBeenCalledWith('Error destroying TS Embed', expect.any(Error)); logSpy.mockReset(); }); }); describe('Fullscreen change handler behavior', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, disableFullscreenPresentation: false, }); }); beforeEach(() => { document.body.innerHTML = getDocumentBody(); }); test('should trigger ExitPresentMode when exiting fullscreen', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { ...defaultViewConfig, liveboardId: 'test-lb', }); await liveboardEmbed.render(); await executeAfterWait(() => { const iframe = getIFrameEl(); expect(iframe).toBeTruthy(); }); mockProcessTrigger.mockResolvedValue({}); liveboardEmbed['setupFullscreenChangeHandler'](); Object.defineProperty(document, 'fullscreenElement', { value: null, writable: true, configurable: true, }); const event = new Event('fullscreenchange'); document.dispatchEvent(event); await executeAfterWait(() => { expect(mockProcessTrigger).toHaveBeenLastCalledWith( expect.any(Object), HostEvent.ExitPresentMode, expect.any(String), expect.any(Object), undefined, ); }); }); test('should not trigger ExitPresentMode when entering fullscreen', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { ...defaultViewConfig, liveboardId: 'test-lb-fullscreen', }); await liveboardEmbed.render(); await executeAfterWait(() => { const iframe = getIFrameEl(); expect(iframe).toBeTruthy(); }); mockProcessTrigger.mockClear(); mockProcessTrigger.mockResolvedValue({}); liveboardEmbed['setupFullscreenChangeHandler'](); Object.defineProperty(document, 'fullscreenElement', { value: getIFrameEl(), writable: true, configurable: true, }); const event = new Event('fullscreenchange'); document.dispatchEvent(event); await executeAfterWait(() => { expect(mockProcessTrigger).not.toHaveBeenCalledWith( expect.any(Object), HostEvent.ExitPresentMode, expect.any(String), expect.any(Object), ); }); }); test('disableFullscreenPresentation undefined defaults to true — no fullscreenchange listener registered', () => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, // disableFullscreenPresentation not set → defaults to true via ?? true }); const embed = new SearchEmbed(getRootEl(), defaultViewConfig); const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); embed['setupFullscreenChangeHandler'](); expect(addEventListenerSpy).not.toHaveBeenCalledWith('fullscreenchange', expect.any(Function)); addEventListenerSpy.mockRestore(); // Restore for other tests in this describe init({ thoughtSpotHost: 'tshost', authType: AuthType.None, disableFullscreenPresentation: false }); }); test('fullscreenchange fires but iFrame.contentWindow is null — trigger NOT called, debug log emitted', () => { const embed = new SearchEmbed(getRootEl(), defaultViewConfig); // Give the embed a fake iFrame with null contentWindow (embed as any).iFrame = { contentWindow: null }; const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); const loggerDebugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => {}); const triggerSpy = jest.spyOn(embed, 'trigger' as any).mockImplementation(() => Promise.resolve(null)); embed['setupFullscreenChangeHandler'](); // Capture the registered handler directly instead of dispatching to document const fullscreenCall = addEventListenerSpy.mock.calls.find((c) => c[0] === 'fullscreenchange'); expect(fullscreenCall).toBeTruthy(); const handler = fullscreenCall[1] as EventListener; Object.defineProperty(document, 'fullscreenElement', { value: null, writable: true, configurable: true }); handler(new Event('fullscreenchange')); expect(triggerSpy).not.toHaveBeenCalled(); expect(loggerDebugSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping ExitPresentMode')); addEventListenerSpy.mockRestore(); loggerDebugSpy.mockRestore(); triggerSpy.mockRestore(); }); }); describe('ShowPreRender with UpdateEmbedParams', () => { const setupPreRenderTest = async ( preRenderId: string, initialConfig: Partial, ) => { createRootEleForEmbed(); mockMessageChannel(); (window as any).ResizeObserver = window.ResizeObserver || jest.fn().mockImplementation(() => ({ disconnect: jest.fn(), observe: jest.fn(), unobserve: jest.fn(), })); const embed1 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId, ...initialConfig, }); await embed1.preRender(); await waitFor(() => !!getIFrameEl()); embed1.isEmbedContainerLoaded = true; mockProcessTrigger.mockClear(); mockProcessTrigger.mockResolvedValue({}); return embed1; }; beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); }); afterEach(() => { const rootEle = document.getElementById('myRoot'); rootEle?.remove(); }); test('should trigger UpdateEmbedParams when showPreRender connects to existing prerendered component', async () => { await setupPreRenderTest('update-params-test', { liveboardId: 'original-lb' }); const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'update-params-test', liveboardId: 'updated-lb', }); embed2.showPreRender(); await executeAfterWait(() => { expect(mockProcessTrigger).toHaveBeenLastCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.objectContaining({ liveboardId: 'updated-lb', }), undefined, ); }); }); test('should trigger UpdateEmbedParams with runtime filters and visible vizs', async () => { await setupPreRenderTest('url-param-test', { liveboardId: 'original-lb' }); const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'url-param-test', liveboardId: 'original-lb', visibleVizs: ['viz-1'], runtimeFilters: [ { columnName: 'Color', operator: RuntimeFilterOp.IN, values: ['red', 'blue'], }, { columnName: 'Region', operator: RuntimeFilterOp.EQ, values: ['North'], }, ], }); embed2.showPreRender(); await executeAfterWait(() => { // Not LastCalledWith: LiveboardEmbed fires Navigate after this. expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.objectContaining({ liveboardId: 'original-lb', visibleVizs: ['viz-1'], runtimeFilters: [ { columnName: 'Color', operator: RuntimeFilterOp.IN, values: ['red', 'blue'], }, { columnName: 'Region', operator: RuntimeFilterOp.EQ, values: ['North'], }, ], }), undefined, ); }); }); test('should trigger UpdateEmbedParams with updated config', async () => { await setupPreRenderTest('preserve-config-test', { liveboardId: 'original-lb', runtimeFilters: [ { columnName: 'Color', operator: RuntimeFilterOp.IN, values: ['red', 'blue'], }, ], }); const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'preserve-config-test', liveboardId: 'original-lb', visibleVizs: ['viz-1', 'viz-2'], runtimeFilters: [ { columnName: 'Region', operator: RuntimeFilterOp.EQ, values: ['North'], }, ], }); embed2.showPreRender(); await executeAfterWait(() => { // Not LastCalledWith: LiveboardEmbed fires Navigate after this. expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.objectContaining({ liveboardId: 'original-lb', visibleVizs: ['viz-1', 'viz-2'], runtimeFilters: [ { columnName: 'Region', operator: RuntimeFilterOp.EQ, values: ['North'], }, ], }), undefined, ); }); }); test('should carry the new config filters in UpdateEmbedParams', async () => { await setupPreRenderTest('params-new-filters', { liveboardId: 'original-lb' }); const runtimeFilters = [ { columnName: 'Color', operator: RuntimeFilterOp.IN, values: ['red', 'blue'], }, ]; const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'params-new-filters', liveboardId: 'original-lb', runtimeFilters, }); embed2.showPreRender(); await executeAfterWait(() => { expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.objectContaining({ runtimeFilters, // The serialized form is the one the container reads. runtimeFilterParams: 'col1=Color&op1=IN&val1=red&val1=blue', }), undefined, ); // Everything rides in the params payload; nothing follows it. const runtimeFilterCalls = mockProcessTrigger.mock.calls.filter( (call: any[]) => call[1] === HostEvent.UpdateRuntimeFilters, ); expect(runtimeFilterCalls).toHaveLength(0); }); }); test('should serialize the filters when they are not excluded from the URL', async () => { await setupPreRenderTest('params-url-filters', { liveboardId: 'original-lb' }); // With the flag off, getDefaultAppInitData leaves runtimeFilterParams // null and a URL render carries the filters in the iframe's query // string. A show cycle has no URL, so the payload has to carry them. const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'params-url-filters', liveboardId: 'original-lb', excludeRuntimeFiltersfromURL: false, runtimeFilters: [ { columnName: 'Color', operator: RuntimeFilterOp.IN, values: ['red'], }, ], }); embed2.showPreRender(); await executeAfterWait(() => { expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.objectContaining({ runtimeFilterParams: 'col1=Color&op1=IN&val1=red', }), undefined, ); }); }); test('should trigger Navigate only after UpdateEmbedParams has settled', async () => { await setupPreRenderTest('navigate-after-params', { liveboardId: 'original-lb', runtimeFilters: [ { columnName: 'Color', operator: RuntimeFilterOp.IN, values: ['red'], }, ], }); const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'navigate-after-params', liveboardId: 'updated-lb', }); embed2.showPreRender(); // Inside the settle window the params are already out // and Navigate is not. await executeAfterWait(() => { const eventTypes = mockProcessTrigger.mock.calls.map((call: any[]) => call[1]); expect(eventTypes).toContain(HostEvent.UpdateEmbedParams); expect(eventTypes).not.toContain(HostEvent.Navigate); }, 50); await executeAfterWait(() => { const eventTypes = mockProcessTrigger.mock.calls.map((call: any[]) => call[1]); // A Navigate that beat the params would make the container load // `updated-lb` while still holding `original-lb`'s runtime filters, // and drop the params that then arrive mid-load (SCAL-336321). expect(eventTypes).toContain(HostEvent.Navigate); expect(eventTypes.indexOf(HostEvent.UpdateEmbedParams)).toBeLessThan( eventTypes.indexOf(HostEvent.Navigate), ); }, 300); }); test('should mark the params empty when the config declares no filters', async () => { await setupPreRenderTest('params-clear-filters', { liveboardId: 'original-lb', runtimeFilters: [ { columnName: 'Color', operator: RuntimeFilterOp.IN, values: ['red', 'blue'], }, ], }); const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'params-clear-filters', liveboardId: 'original-lb', }); embed2.showPreRender(); await executeAfterWait(() => { // Not null and not '': the container only reads this field when it // is truthy, so either would leave the previous embed's filters in // place. A lone separator passes that gate and parses to {}. expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.objectContaining({ runtimeFilterParams: '&' }), undefined, ); const runtimeFilterCalls = mockProcessTrigger.mock.calls.filter( (call: any[]) => call[1] === HostEvent.UpdateRuntimeFilters, ); expect(runtimeFilterCalls).toHaveLength(0); }); }); test('should mark the params empty for an explicitly empty filter array', async () => { await setupPreRenderTest('params-empty-array', { liveboardId: 'original-lb', runtimeFilters: [ { columnName: 'Color', operator: RuntimeFilterOp.IN, values: ['red'], }, ], }); // `runtimeFilters: []` is the shape the customer reported as not // resetting anything. const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'params-empty-array', liveboardId: 'original-lb', runtimeFilters: [], }); embed2.showPreRender(); await executeAfterWait(() => { expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.objectContaining({ runtimeFilterParams: '&' }), undefined, ); }); }); test('should mark the params empty when no config in the chain has filters', async () => { await setupPreRenderTest('params-no-filters', { liveboardId: 'original-lb' }); const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'params-no-filters', liveboardId: 'updated-lb', }); embed2.showPreRender(); await executeAfterWait(() => { const paramsCall = mockProcessTrigger.mock.calls.find( (call: any[]) => call[1] === HostEvent.UpdateEmbedParams, ); expect(paramsCall?.[3]).not.toHaveProperty('runtimeFilters'); expect(paramsCall?.[3].runtimeFilterParams).toBe('&'); }); }); test('should state each embed filters as the pre-render is handed along', async () => { await setupPreRenderTest('params-chain', { liveboardId: 'original-lb', runtimeFilters: [ { columnName: 'Color', operator: RuntimeFilterOp.IN, values: ['red', 'blue'], }, ], }); // Second in the chain declares none, so it empties the field rather // than letting the creator's filters stand. const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'params-chain', liveboardId: 'second-lb', }); await embed2.showPreRender(); await executeAfterWait(() => { expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.objectContaining({ runtimeFilterParams: '&' }), undefined, ); }); mockProcessTrigger.mockClear(); // Third declares its own, and states them without reference to either // embed before it. const embed3 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'params-chain', liveboardId: 'third-lb', runtimeFilters: [ { columnName: 'Region', operator: RuntimeFilterOp.EQ, values: ['North'], }, ], }); await embed3.showPreRender(); await executeAfterWait(() => { expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.objectContaining({ runtimeFilterParams: 'col1=Region&op1=EQ&val1=North', }), undefined, ); }); }); test('should carry the new config runtime parameters in UpdateEmbedParams', async () => { await setupPreRenderTest('params-parameters', { liveboardId: 'original-lb', runtimeParameters: [{ name: 'Region Param', value: 'East' }], }); const runtimeParameters = [{ name: 'Region Param', value: 'West' }]; const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'params-parameters', liveboardId: 'original-lb', runtimeParameters, }); embed2.showPreRender(); await executeAfterWait(() => { expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.objectContaining({ runtimeParameters, runtimeParameterParams: 'param1=Region%20Param¶mVal1=West', }), undefined, ); // A parameter always carries a value, so the payload says everything // there is to say — no follow-up event. const parameterCalls = mockProcessTrigger.mock.calls.filter( (call: any[]) => call[1] === HostEvent.UpdateParameters, ); expect(parameterCalls).toHaveLength(0); }); }); test('should mark the params empty when the config declares no parameters', async () => { await setupPreRenderTest('params-clear-parameters', { liveboardId: 'original-lb', runtimeParameters: [{ name: 'Region Param', value: 'East' }], }); const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'params-clear-parameters', liveboardId: 'original-lb', }); embed2.showPreRender(); await executeAfterWait(() => { // Same gate as the filters: the container only reads this field // when it is truthy, so null would leave 'East' standing. expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.objectContaining({ runtimeParameterParams: '&' }), undefined, ); }); }); test('should keep an empty string parameter value distinct from having none', async () => { await setupPreRenderTest('params-empty-value', { liveboardId: 'original-lb' }); // `paramVal1=` is that parameter's value, not an absence — so it must // not collapse to the empty marker. const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'params-empty-value', liveboardId: 'original-lb', runtimeParameters: [{ name: 'Region Param', value: '' }], }); embed2.showPreRender(); await executeAfterWait(() => { expect(mockProcessTrigger).toHaveBeenCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.objectContaining({ runtimeParameterParams: 'param1=Region%20Param¶mVal1=', }), undefined, ); }); }); test('should hand the pre-render over to the embed being shown', async () => { const embed1 = await setupPreRenderTest('take-over-state', { liveboardId: 'original-lb' }); const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'take-over-state', liveboardId: 'second-lb', }); await embed2.showPreRender(); // It connected to embed1's pre-render and is now what the wrapper // points at, so getPreRenderObj() means "what is showing". expect((embed2 as any).getPreRenderObj()).toBe(embed2); expect((embed1 as any).getPreRenderObj()).toBe(embed2); }); test('should not strand callbacks when the container loads after the shown embed was hidden', async () => { // The creator is subscribed but the container has NOT announced itself // yet, which is the state a real pre-render starts in. const embed1 = await setupPreRenderTest('loaded-after-hide', { liveboardId: 'original-lb' }); embed1.isEmbedContainerLoaded = false; // Second embed shows, takes the pre-render over, then is hidden — which // unsubscribes it, so it will never hear EmbedListenerReady itself. const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'loaded-after-hide', liveboardId: 'second-lb', }); await embed2.showPreRender(); embed2.hidePreRender(); expect(embed2.isEmbedContainerLoaded).toBe(false); // Container comes up and the creator hears it. (embed1 as any).createEmbedContainerHandler(EmbedEvent.EmbedListenerReady)(); mockProcessTrigger.mockClear(); mockProcessTrigger.mockResolvedValue({}); // A third embed reads the state off embed2, which owns the wrapper and // never learned the container was up. Reading a per-instance snapshot // here leaves UpdateEmbedParams queued forever and the pre-render stuck // on the previous liveboard. const embed3 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'loaded-after-hide', liveboardId: 'third-lb', }); await embed3.showPreRender(); expect(embed3.isEmbedContainerLoaded).toBe(true); await executeAfterWait(() => { const eventTypes = mockProcessTrigger.mock.calls.map((call: any[]) => call[1]); expect(eventTypes).toContain(HostEvent.UpdateEmbedParams); }, 300); }); test('should not reconcile runtime filters unless the view config opts in', async () => { await setupPreRenderTest('reconcile-flag-off', { liveboardId: 'original-lb', runtimeFilters: [ { columnName: 'Color', operator: RuntimeFilterOp.IN, values: ['red', 'blue'], }, ], }); // Same shape as the clear-filters case above, minus the flag: the // previous config left filters on the shared pre-render and this one // declares none. Without preRenderConfig.reconcileRuntimeParams the SDK // leaves that to the container, so no UpdateRuntimeFilters goes out. const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'reconcile-flag-off', liveboardId: 'original-lb', }); embed2.showPreRender(); await executeAfterWait(() => { const eventTypes = mockProcessTrigger.mock.calls.map((call: any[]) => call[1]); expect(eventTypes).toContain(HostEvent.UpdateEmbedParams); expect(eventTypes).not.toContain(HostEvent.UpdateRuntimeFilters); }); }); test('should handle error when getUpdateEmbedParamsObject fails during showPreRender', async () => { await setupPreRenderTest('error-test', { liveboardId: 'original-lb' }); const handleErrorSpy = jest.spyOn(LiveboardEmbed.prototype as any, 'handleError'); const embed2 = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'error-test', liveboardId: 'updated-lb', }); const mockError = new Error('Failed to get params'); jest.spyOn(embed2 as any, 'getUpdateEmbedParamsObject').mockRejectedValue(mockError); embed2.showPreRender(); await executeAfterWait(() => { expect(handleErrorSpy).toHaveBeenCalledWith( expect.objectContaining({ errorType: ErrorDetailsTypes.API, message: 'Failed to get params', code: EmbedErrorCodes.UPDATE_PARAMS_FAILED, error: 'Failed to get params', }), ); expect(mockProcessTrigger).not.toHaveBeenCalledWith( expect.any(Object), HostEvent.UpdateEmbedParams, expect.any(String), expect.any(Object), ); }); handleErrorSpy.mockRestore(); }); test('should set allow "local-network-access" correctly on the iframe', async () => { const appEmbed = new AppEmbed(getRootEl(), { frameParams: { width: '100%', height: '100%' }, }); await appEmbed.render(); const iframe = getIFrameEl(); expect(iframe.allow).toContain('local-network-access'); expect(iframe.allow).toContain('fullscreen'); expect(iframe.allow).toContain('clipboard-read'); expect(iframe.allow).toContain('clipboard-write'); }); describe('shouldSkipEvent', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); }); // Matches the structure produced by createValidationError / // embedErrorDetails const makeNestedValidationData = (message = 'invalid payload') => ({ type: EmbedEvent.Error, data: { errorType: 'VALIDATION_ERROR', message, code: EmbedErrorCodes.HOST_EVENT_VALIDATION, error: message, }, }); // Matches the flat structure where errorType sits at the top level of // data const makeFlatValidationData = (message = 'invalid payload') => ({ errorType: EmbedErrorCodes.HOST_EVENT_VALIDATION, message, code: EmbedErrorCodes.HOST_EVENT_VALIDATION, }); const makeEmbed = (viewConfig: Partial) => { const embed = new SearchEmbed(getRootEl(), { ...defaultViewConfig, ...viewConfig, }); return embed; }; test('skips Error event and logs warning when useHostEventsV2 is true and shouldBypassPayloadValidation is true', () => { jest.spyOn(logger, 'warn'); const errorHandler = jest.fn(); const embed = makeEmbed({ useHostEventsV2: true, shouldBypassPayloadValidation: true }); embed.on(EmbedEvent.Error, errorHandler); (embed as any).executeCallbacks(EmbedEvent.Error, makeNestedValidationData()); expect(errorHandler).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( 'Host Event Validation failed: invalid payload', ); }); test('skips Error event when errorType is resolved from data.data.code (nested format)', () => { jest.spyOn(logger, 'warn'); const errorHandler = jest.fn(); const embed = makeEmbed({ useHostEventsV2: true, shouldBypassPayloadValidation: true }); embed.on(EmbedEvent.Error, errorHandler); (embed as any).executeCallbacks( EmbedEvent.Error, makeNestedValidationData('nested error'), ); expect(errorHandler).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith('Host Event Validation failed: nested error'); }); test('skips Error event when errorType is resolved from data.errorType (flat format)', () => { jest.spyOn(logger, 'warn'); const errorHandler = jest.fn(); const embed = makeEmbed({ useHostEventsV2: true, shouldBypassPayloadValidation: true }); embed.on(EmbedEvent.Error, errorHandler); (embed as any).executeCallbacks(EmbedEvent.Error, makeFlatValidationData()); expect(errorHandler).not.toHaveBeenCalled(); }); test('delivers Error event to handler when useHostEventsV2 is true and shouldBypassPayloadValidation is undefined', () => { const errorHandler = jest.fn(); const embed = makeEmbed({ useHostEventsV2: true, shouldBypassPayloadValidation: undefined, }); embed.on(EmbedEvent.Error, errorHandler); (embed as any).executeCallbacks(EmbedEvent.Error, makeNestedValidationData()); expect(errorHandler).toHaveBeenCalled(); }); test('delivers Error event to handler when useHostEventsV2 is true and shouldBypassPayloadValidation is false', () => { const errorHandler = jest.fn(); const embed = makeEmbed({ useHostEventsV2: true, shouldBypassPayloadValidation: false, }); embed.on(EmbedEvent.Error, errorHandler); (embed as any).executeCallbacks(EmbedEvent.Error, makeNestedValidationData()); expect(errorHandler).toHaveBeenCalled(); }); test('skips Error event when useHostEventsV2 is false regardless of shouldBypassPayloadValidation', () => { jest.spyOn(logger, 'warn'); const errorHandler = jest.fn(); const embed = makeEmbed({ useHostEventsV2: false, shouldBypassPayloadValidation: undefined, }); embed.on(EmbedEvent.Error, errorHandler); (embed as any).executeCallbacks(EmbedEvent.Error, makeNestedValidationData()); expect(errorHandler).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( 'Host Event Validation failed: invalid payload', ); }); test('logs warning with undefined message when flat format has no nested data', () => { jest.spyOn(logger, 'warn'); const embed = makeEmbed({ useHostEventsV2: true, shouldBypassPayloadValidation: true }); embed.on(EmbedEvent.Error, jest.fn()); (embed as any).executeCallbacks(EmbedEvent.Error, makeFlatValidationData()); expect(logger.warn).toHaveBeenCalledWith('Host Event Validation failed: undefined'); }); test('skips Error event when useHostEventsV2 is false and shouldBypassPayloadValidation is true', () => { jest.spyOn(logger, 'warn'); const errorHandler = jest.fn(); const embed = makeEmbed({ useHostEventsV2: false, shouldBypassPayloadValidation: true, }); embed.on(EmbedEvent.Error, errorHandler); (embed as any).executeCallbacks(EmbedEvent.Error, makeNestedValidationData()); expect(errorHandler).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( 'Host Event Validation failed: invalid payload', ); }); test('skips via handleError when shouldBypassPayloadValidation is true', () => { jest.spyOn(logger, 'warn'); const errorHandler = jest.fn(); const embed = makeEmbed({ useHostEventsV2: true, shouldBypassPayloadValidation: true }); embed.on(EmbedEvent.Error, errorHandler); (embed as any).handleError({ type: EmbedEvent.Error, data: { errorType: 'VALIDATION_ERROR', message: 'bad payload', code: EmbedErrorCodes.HOST_EVENT_VALIDATION, error: 'bad payload', }, }); expect(errorHandler).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith('Host Event Validation failed: bad payload'); }); test('delivers Error event to EmbedEvent.ALL handler when not skipped', () => { const allHandler = jest.fn(); const embed = makeEmbed({ useHostEventsV2: true, shouldBypassPayloadValidation: false, }); embed.on(EmbedEvent.ALL, allHandler); (embed as any).executeCallbacks(EmbedEvent.Error, makeNestedValidationData()); expect(allHandler).toHaveBeenCalled(); }); test('does not skip non-Error events even with HOST_EVENT_VALIDATION error code', () => { const customActionHandler = jest.fn(); const embed = makeEmbed({ useHostEventsV2: true, shouldBypassPayloadValidation: false, }); embed.on(EmbedEvent.CustomAction, customActionHandler); (embed as any).executeCallbacks(EmbedEvent.CustomAction, { data: { code: EmbedErrorCodes.HOST_EVENT_VALIDATION }, }); expect(customActionHandler).toHaveBeenCalled(); }); test('does not skip Error events with unrelated error codes', () => { const errorHandler = jest.fn(); const embed = makeEmbed({ useHostEventsV2: true, shouldBypassPayloadValidation: false, }); embed.on(EmbedEvent.Error, errorHandler); (embed as any).executeCallbacks(EmbedEvent.Error, { errorType: 'SOME_OTHER_ERROR', message: 'something else failed', }); expect(errorHandler).toHaveBeenCalled(); }); }); describe('constructor init-path branching (SCAL-315058)', () => { beforeEach(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); }); it('sets hostElement from domSelector', () => { const embed = new SearchEmbed(getRootEl(), defaultViewConfig); expect((embed as any).hostElement).toBe(getRootEl()); }); it('skips isReadyForRenderPromise when init already completed', () => { jest.spyOn(baseInstance, 'getIsInitCompleted').mockReturnValue(true); const embed = new SearchEmbed(getRootEl(), defaultViewConfig); expect((embed as any).shouldWaitForRenderPromise).toBe(false); expect((embed as any).isReadyForRenderPromise).toBeUndefined(); }); it('sets isReadyForRenderPromise when init not yet completed', () => { jest.spyOn(baseInstance, 'getIsInitCompleted').mockReturnValue(false); const embed = new SearchEmbed(getRootEl(), defaultViewConfig); expect((embed as any).shouldWaitForRenderPromise).toBe(true); expect((embed as any).isReadyForRenderPromise).toBeInstanceOf(Promise); }); it('afterInit runs synchronously when init already completed', () => { jest.spyOn(baseInstance, 'getIsInitCompleted').mockReturnValue(true); const embed = new SearchEmbed(getRootEl(), defaultViewConfig); // thoughtSpotHost is set by afterInit; // must be non-empty after constructor expect((embed as any).thoughtSpotHost).toBeTruthy(); }); it('does not set isReadyForRenderPromise when shouldWaitForRenderPromise is false', () => { jest.spyOn(baseInstance, 'getIsInitCompleted').mockReturnValue(true); const embed = new SearchEmbed(getRootEl(), defaultViewConfig); expect((embed as any).shouldWaitForRenderPromise).toBe(false); // isReadyForRenderPromise must be undefined // so no unnecessary await occurs expect((embed as any).isReadyForRenderPromise).toBeUndefined(); }); it('shouldWaitForRenderPromise flips to false after promise settles', async () => { jest.spyOn(baseInstance, 'getIsInitCompleted').mockReturnValue(false); const embed = new SearchEmbed(getRootEl(), defaultViewConfig); expect((embed as any).shouldWaitForRenderPromise).toBe(true); await (embed as any).isReadyForRenderPromise; expect((embed as any).shouldWaitForRenderPromise).toBe(false); }); it('calls throwInitError when getInitPromise rejects', async () => { jest.spyOn(baseInstance, 'getIsInitCompleted').mockReturnValue(false); jest.spyOn(baseInstance, 'getInitPromise').mockReturnValue( Promise.reject(new Error('init failed')), ); const embed = new SearchEmbed(getRootEl(), defaultViewConfig); const throwInitErrorSpy = jest.spyOn(embed as any, 'throwInitError'); await (embed as any).isReadyForRenderPromise; expect(throwInitErrorSpy).toHaveBeenCalled(); }); it('shouldWaitForRenderPromise flips to false even when getInitPromise rejects', async () => { jest.spyOn(baseInstance, 'getIsInitCompleted').mockReturnValue(false); jest.spyOn(baseInstance, 'getInitPromise').mockReturnValue( Promise.reject(new Error('init failed')), ); const embed = new SearchEmbed(getRootEl(), defaultViewConfig); await (embed as any).isReadyForRenderPromise; expect((embed as any).shouldWaitForRenderPromise).toBe(false); }); }); describe('preRender ID object includes placeHolder (SCAL-315058)', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); }); it('getPreRenderIds returns placeHolder key', () => { createRootEleForEmbed(); const embed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'ph-test', liveboardId: 'lb-id', }); const ids = embed.getPreRenderIds(); expect(ids.placeHolder).toBe('tsEmbed-pre-render-placeholder-ph-test'); }); }); describe('isPreRenderConnected logic (SCAL-315058)', () => { beforeAll(() => { init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); }); it('returns false when preRenderWrapper is absent', () => { createRootEleForEmbed(); const embed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'conn-test', liveboardId: 'lb-id', }); expect((embed as any).isPreRenderConnected()).toBe(false); }); it('returns true once preRenderWrapper and preRenderChild are set', () => { createRootEleForEmbed(); const embed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'conn-test-2', liveboardId: 'lb-id', }); (embed as any).preRenderWrapper = document.createElement('div'); (embed as any).preRenderChild = document.createElement('div'); expect((embed as any).isPreRenderConnected()).toBe(true); }); }); describe('showPreRender inserts placeholder into hostElement (SCAL-315058)', () => { beforeAll(() => { // Clear spy implementations that may have leaked from prior // describe blocks (e.g. getIsInitCompleted/getInitPromise mocks set // in 'constructor init-path branching' persist across describes // because clearAllMocks only clears call history, not // implementations). jest.restoreAllMocks(); jest.spyOn(authInstance, 'postLoginService').mockResolvedValue(undefined); init({ thoughtSpotHost: 'tshost', authType: AuthType.None, }); (window as any).ResizeObserver = jest.fn().mockImplementation(() => ({ disconnect: jest.fn(), observe: jest.fn(), unobserve: jest.fn(), })); }); it('showPreRender creates a placeholder element with the correct id', async () => { createRootEleForEmbed(); const embed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'ph-lifecycle-test', liveboardId: 'lb-id', }); await embed.preRender(); await waitFor( () => !!document.querySelector('#tsEmbed-pre-render-child-ph-lifecycle-test'), ); await embed.showPreRender(); const placeholderEle = ( embed as any ).getPreRenderPlaceHolderElement() as HTMLDivElement; expect(placeholderEle).not.toBeNull(); expect(placeholderEle.id).toBe('tsEmbed-pre-render-placeholder-ph-lifecycle-test'); // placeholder is attached to a parent element (inside hostElement) expect(placeholderEle.parentElement).not.toBeNull(); embed.destroy(); }); // showPreRender located the stale placeholder with a subtree-wide // querySelector but removed it with hostElement.removeChild(), which // only accepts direct children. With fullHeight the placeholder can be // nested deeper than a direct child, so removeChild threw // "NotFoundError: Failed to execute 'removeChild' on 'Node'". it('showPreRender removes a placeholder nested below a direct child without throwing', async () => { createRootEleForEmbed(); const embed = new LiveboardEmbed('#tsEmbedDiv', { preRenderId: 'ph-nested-test', liveboardId: 'lb-id', fullHeight: true, } as any); await embed.preRender(); await waitFor( () => !!document.querySelector('#tsEmbed-pre-render-child-ph-nested-test'), ); const hostElement = document.querySelector('#tsEmbedDiv') as HTMLElement; const placeHolderId = 'tsEmbed-pre-render-placeholder-ph-nested-test'; // Simulate the fullHeight layout where the placeholder ends up as // a grandchild of the host element rather than a direct child. const intermediateWrapper = document.createElement('div'); const staleNestedPlaceholder = document.createElement('div'); staleNestedPlaceholder.id = placeHolderId; intermediateWrapper.appendChild(staleNestedPlaceholder); hostElement.appendChild(intermediateWrapper); expect(hostElement.querySelector(`#${placeHolderId}`)).toBe(staleNestedPlaceholder); // Before the fix this threw NotFoundError. await expect(embed.showPreRender()).resolves.toBeDefined(); // The stale nested placeholder is gone; the fresh one is a direct child. expect(staleNestedPlaceholder.isConnected).toBe(false); const currentPlaceholder = ( embed as any ).getPreRenderPlaceHolderElement() as HTMLDivElement; expect(currentPlaceholder.parentElement).toBe(hostElement); embed.destroy(); }); }); describe('trigger typing contracts (compile-time)', () => { // Guards the BASE TsEmbed.trigger — the implementation every // non-Liveboard embed (SearchEmbed, AppEmbed, ConversationEmbed, …) // calls. LiveboardEmbed's override is covered in liveboard.spec.ts; // this locks the base defaults so a "lint fix" that drops them here // can't silently de-type every embed except Liveboard. // Enforced by ts-jest at compile time. type IsAny = 0 extends 1 & T ? true : false; type Extends = A extends B ? true : false; it('infers typed response for no-payload calls on the base trigger', () => { const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); const getTabs = () => searchEmbed.trigger(HostEvent.GetTabs); type TabsResponse = Awaited>; const tabsIsTyped: IsAny = false; const tabsHasContractFields: Extends< TabsResponse, { numberOfTabs: number; orderedTabIds: string[] } > = true; const probeUnknownField = (r: TabsResponse) => // @ts-expect-error — field not on the GetTabs contract (was silent when `any`) r.tabCount; void probeUnknownField; const reload = () => searchEmbed.trigger(HostEvent.Reload); type ReloadResponse = Awaited>; const reloadStaysAny: IsAny = true; expect([tabsIsTyped, tabsHasContractFields, reloadStaysAny]) .toEqual([false, true, true]); }); }); });