diff --git a/src/index.ts b/src/index.ts index 8e7cc8ba2..fdf80d950 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,7 +88,23 @@ import { uploadMixpanelEvent, MIXPANEL_EVENT } from './mixpanel-service'; import { tokenizedFetch } from './tokenizedFetch'; import { getAnswerFromQuery } from './utils/graphql/nlsService/nls-answer-service'; import { createLiveboardWithAnswers } from './utils/liveboardService/liveboardService'; -import { UIPassthroughEvent } from './embed/hostEventClient/contracts'; +import { + UIPassthroughEvent, + UIPassthroughContractBase, + UIPassthroughRequest, + UIPassthroughResponse, + UIPassthroughArrayResponse, + HostEventRequest, + HostEventResponse, + TriggerPayload, + TriggerResponse, + LiveboardTab, + ApplicabilityLevel, + Applicability, + FilterUpdate, + LiveboardFilter, + LiveboardParameter, +} from './embed/hostEventClient/contracts'; export { init, @@ -170,6 +186,24 @@ export { VizPoint, CustomActionPayload, UIPassthroughEvent, + // Host event payload / response contracts. These describe what to send with + // `embed.trigger(HostEvent.X, payload)` and what the returned promise + // resolves with. See `UIPassthroughContractBase` for the per-event + // request/response shapes. + UIPassthroughContractBase, + UIPassthroughRequest, + UIPassthroughResponse, + UIPassthroughArrayResponse, + HostEventRequest, + HostEventResponse, + TriggerPayload, + TriggerResponse, + LiveboardTab, + ApplicabilityLevel, + Applicability, + FilterUpdate, + LiveboardFilter, + LiveboardParameter, ListPageColumns, DataPanelCustomColumnGroupsAccordionState, CustomActionsPosition, diff --git a/src/react/all-types-export.parity.spec.ts b/src/react/all-types-export.parity.spec.ts new file mode 100644 index 000000000..6fd958656 --- /dev/null +++ b/src/react/all-types-export.parity.spec.ts @@ -0,0 +1,195 @@ +/** + * Parity gate: every symbol exported from `src/index.ts` must also be exported + * from `src/react/all-types-export.ts`. + * + * `package.json` `exports` restricts consumers to two entry points — `.` and + * `./react` — and the React surface is a HAND-MAINTAINED duplicate export list, + * not a `export * from '../index'`. So a symbol added only to `src/index.ts` is + * invisible to every React consumer. + * + * The sibling `all-types-export.spec.ts` cannot catch this: it inspects the + * module at runtime, and type-only exports (interfaces, type aliases) are + * erased by the compiler. This spec therefore compares the two files as TEXT. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +const INDEX_PATH = path.join(__dirname, '..', 'index.ts'); +const REACT_EXPORT_PATH = path.join(__dirname, 'all-types-export.ts'); + +/** + * Symbols intentionally absent from the React surface. + * Add here only with a reason — every entry is a thing React users cannot use. + */ +const REACT_EXEMPT = new Set([ + // React has its own component wrappers for these; the raw classes are not + // part of the /react surface. + 'SearchEmbed', + 'SearchBarEmbed', + 'LiveboardEmbed', + 'AppEmbed', + 'SpotterEmbed', + 'SpotterAgentEmbed', + 'ConversationEmbed', + 'BodylessConversation', + 'PreRenderedSearchEmbed', + 'PreRenderedSearchBarEmbed', + 'PreRenderedLiveboardEmbed', + 'PreRenderedAppEmbed', + 'PreRenderedConversationEmbed', + 'PinboardEmbed', + 'TsEmbed', + 'V1Embed', +]); + +/** + * PRE-EXISTING gaps, discovered when this spec was introduced. These are + * symbols React consumers cannot import today — most look like oversights + * rather than deliberate omissions (e.g. `ContextType`, `CustomActionPayload`, + * `VizPoint`, the Spotter view configs). + * + * This list may only ever SHRINK. Do not add to it — a new entry means a + * symbol was added to `src/index.ts` without adding it to the React surface, + * which is exactly what this spec exists to prevent. Removing entries (by + * exporting them from `all-types-export.ts`) is a welcome follow-up; it is + * deliberately out of scope for the change that introduced this gate. + */ +const KNOWN_REACT_GAPS = new Set([ + 'AnswerService', + 'AutoMCPFrameRendererViewConfig', + 'BackgroundFormatType', + 'BodylessConversationViewConfig', + 'ConditionalFormattingComparisonType', + 'ConditionalFormattingOperator', + 'ContextMenuTriggerOptions', + 'ContextType', + 'ConversationViewConfig', + 'CustomActionPayload', + 'DataLabelFilterOperator', + 'EmbedErrorCodes', + 'EmbedErrorDetailsEvent', + 'ErrorDetailsTypes', + 'HomeLeftNavItem', + 'HomePage', + 'HomePageSearchBarMode', + 'HomepageModule', + 'LegendPosition', + 'ListPage', + 'ListPageColumns', + 'LogLevel', + 'MIXPANEL_EVENT', + 'PrimaryNavbarVersion', + 'SessionInterface', + 'SpotterAgentEmbedViewConfig', + 'SpotterChatViewConfig', + 'SpotterEmbedViewConfig', + 'SpotterQueryMode', + 'SpotterShareConversationConfig', + 'SpotterSidebarViewConfig', + 'SpotterVizConfig', + 'SpotterVizLoaderTip', + 'SpotterVizStarterPrompt', + 'TableContentDensity', + 'TableTheme', + 'UnderlyingDataPoint', + 'VisualizationOverrides', + 'VizPoint', + // Standalone helpers re-exported from src/index.ts outside the main block. + 'createLiveboardWithAnswers', + 'executeTML', + 'executeTMLInput', + 'exportTML', + 'exportTMLInput', + 'getAnswerFromQuery', + 'startAutoMCPFrameRenderer', + 'tokenizedFetch', +]); + +/** + * Extract identifiers from every `export { ... }` block in a source file. + * Handles `a`, `a as b` (records the exported name `b`), comments and trailing + * commas. + */ +const extractExportedNames = (source: string): Set => { + const names = new Set(); + const blockRe = /export\s*\{([\s\S]*?)\}/g; + let match = blockRe.exec(source); + while (match !== null) { + match[1] + // strip line and block comments + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\n]*/g, '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .forEach((entry) => { + // `foo as bar` exports the name `bar` + const parts = entry.split(/\s+as\s+/); + const exported = (parts[parts.length - 1] || '').trim(); + if (/^[A-Za-z_$][\w$]*$/.test(exported)) { + names.add(exported); + } + }); + match = blockRe.exec(source); + } + return names; +}; + +describe('react/all-types-export parity with index', () => { + it('exports every symbol that src/index.ts exports', () => { + const indexNames = extractExportedNames(fs.readFileSync(INDEX_PATH, 'utf8')); + const reactNames = extractExportedNames(fs.readFileSync(REACT_EXPORT_PATH, 'utf8')); + + // Sanity: the extractor found something. Guards against a regex that + // silently matches nothing after a refactor. + expect(indexNames.size).toBeGreaterThan(50); + expect(reactNames.size).toBeGreaterThan(50); + + const missing = [...indexNames] + .filter((name) => !reactNames.has(name)) + .filter((name) => !REACT_EXEMPT.has(name)) + .filter((name) => !KNOWN_REACT_GAPS.has(name)) + .sort(); + + if (missing.length > 0) { + throw new Error( + 'These symbols are exported from src/index.ts but NOT from ' + + 'src/react/all-types-export.ts, so React consumers cannot ' + + `import them:\n ${missing.join('\n ')}\n\n` + + 'Add them to src/react/all-types-export.ts. Do NOT add them ' + + 'to KNOWN_REACT_GAPS — that list is a shrinking backlog of ' + + 'pre-existing gaps and must never grow.', + ); + } + }); + + it('KNOWN_REACT_GAPS only lists symbols that are genuinely still missing', () => { + // Ratchet: once a gap is fixed, its entry must be deleted, so the list + // can only shrink. + const indexNames = extractExportedNames(fs.readFileSync(INDEX_PATH, 'utf8')); + const reactNames = extractExportedNames(fs.readFileSync(REACT_EXPORT_PATH, 'utf8')); + + const stale = [...KNOWN_REACT_GAPS] + .filter((name) => reactNames.has(name) || !indexNames.has(name)) + .sort(); + + if (stale.length > 0) { + throw new Error( + 'These entries in KNOWN_REACT_GAPS are no longer gaps (they are ' + + 'now exported from the React surface, or no longer exported ' + + `from src/index.ts). Delete them from the list:\n ${stale.join('\n ')}`, + ); + } + }); + + it('exposes the host event contract types on the React surface', () => { + const reactNames = extractExportedNames(fs.readFileSync(REACT_EXPORT_PATH, 'utf8')); + [ + 'UIPassthroughContractBase', + 'HostEventRequest', + 'HostEventResponse', + 'TriggerPayload', + 'TriggerResponse', + ].forEach((name) => expect(reactNames.has(name)).toBe(true)); + }); +}); diff --git a/src/react/all-types-export.ts b/src/react/all-types-export.ts index 1d811a105..ff619ad54 100644 --- a/src/react/all-types-export.ts +++ b/src/react/all-types-export.ts @@ -56,6 +56,22 @@ export { RuntimeParameter, resetCachedAuthToken, UIPassthroughEvent, + // Host event payload / response contracts — kept in sync with src/index.ts + // (enforced by all-types-export.parity.spec.ts). + UIPassthroughContractBase, + UIPassthroughRequest, + UIPassthroughResponse, + UIPassthroughArrayResponse, + HostEventRequest, + HostEventResponse, + TriggerPayload, + TriggerResponse, + LiveboardTab, + ApplicabilityLevel, + Applicability, + FilterUpdate, + LiveboardFilter, + LiveboardParameter, DataPanelCustomColumnGroupsAccordionState, InterceptedApiType, CustomActionsPosition, diff --git a/src/types.ts b/src/types.ts index 678cd0ec4..322a0c9fb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4305,6 +4305,7 @@ export enum HostEvent { * const url = await appEmbed.trigger(HostEvent.GetIframeUrl, {}, ContextType.Answer); * console.log("iFrameURL", url); * ``` + * @returns iframeUrl - The URL currently loaded in the embedded iframe. * @version SDK: 1.35.0 | ThoughtSpot: 10.4.0.cl */ GetIframeUrl = 'GetIframeUrl', @@ -4501,6 +4502,10 @@ export enum HostEvent { * liveboardEmbed.trigger(HostEvent.getExportRequestForCurrentPinboard).then( * data=>console.log(data)) * ``` + * @returns data - Object with a `v2Content` string: the export request + * payload for the current Liveboard. + * @returns type - The passthrough event name, + * `getExportRequestForCurrentPinboard`. * @version SDK: 1.13.0 | ThoughtSpot: 8.5.0.cl, 8.8.1.sw */ getExportRequestForCurrentPinboard = 'getExportRequestForCurrentPinboard', @@ -4603,6 +4608,9 @@ export enum HostEvent { * }, ContextType.Spotter); * ``` * + * @returns liveboardId - GUID of the Liveboard the Answer was pinned to. + * @returns tabId - GUID of the tab within that Liveboard. + * @returns vizId - GUID of the newly created visualization. * @version SDK: 1.15.0 | ThoughtSpot: 8.7.0.cl, 8.8.1.sw */ Pin = 'pin', @@ -5092,6 +5100,7 @@ export enum HostEvent { * console.log(tml.answer); * }); * ``` + * @returns The TML representation of the current Answer, as an object. * @version SDK: 1.18.0 | ThoughtSpot: 8.10.0.cl, 9.0.1.sw * @important */ @@ -5536,6 +5545,8 @@ export enum HostEvent { * const data = await liveboardEmbed.trigger(HostEvent.GetFilters, {}, ContextType.Liveboard); * console.log('filters', data); * ``` + * @returns liveboardFilters - Filters applied on the Liveboard. + * @returns runtimeFilters - Runtime filters applied on the Liveboard. * @version SDK: 1.23.0 | ThoughtSpot: 9.4.0.cl */ GetFilters = 'getFilters', @@ -5685,6 +5696,9 @@ export enum HostEvent { * console.log('tabs', tabDetails); * }); * ``` + * @returns orderedTabIds - Tab GUIDs in the order they appear. + * @returns numberOfTabs - Total number of tabs on the Liveboard. + * @returns Tabs - Tab details, each with at least `id` and `name`. * @version SDK: 1.26.0 | ThoughtSpot: 9.7.0.cl */ GetTabs = 'getTabs', @@ -5759,6 +5773,9 @@ export enum HostEvent { * // Alternative direct usage (not recommended) * const {session} = await embed.trigger( HostEvent.GetAnswerSession ) * ``` + * @returns session - Session identifier for the Answer, for use with the + * `AnswerService`. + * @returns embedAnswerData - Data backing the Answer, when available. * @version SDK: 1.26.0 | ThoughtSpot: 9.10.0.cl, 10.1.0.sw */ GetAnswerSession = 'getAnswerSession', @@ -5892,6 +5909,7 @@ export enum HostEvent { * console.log('parameters', parameters); * }); * ``` + * @returns parameters - Parameters currently applied on the Liveboard. * @version SDK: 1.29.0 | ThoughtSpot: 10.1.0.cl, 10.1.0.sw */ GetParameters = 'GetParameters', @@ -6021,6 +6039,10 @@ export enum HostEvent { * description: "Generated from Spotter" * }, ContextType.Spotter); * ``` + * @returns answerId - GUID of the saved Answer. + * @returns saveResponse - Raw response from the save operation. + * @returns shareResponse - Raw response from the share operation, when the + * Answer was also made discoverable. * @version SDK: 1.36.0 | ThoughtSpot: 10.6.0.cl */ SaveAnswer = 'saveAnswer',