@thoughtspot/visual-embed-sdk 1.53.1 → 1.53.2-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/cjs/package.json +1 -1
  2. package/cjs/src/auth.d.ts.map +1 -1
  3. package/cjs/src/auth.js +25 -6
  4. package/cjs/src/auth.js.map +1 -1
  5. package/cjs/src/auth.spec.js +40 -8
  6. package/cjs/src/auth.spec.js.map +1 -1
  7. package/cjs/src/contracts/contracts.spec.js +165 -2
  8. package/cjs/src/contracts/contracts.spec.js.map +1 -1
  9. package/cjs/src/contracts/embed-event-payloads.d.ts +212 -14
  10. package/cjs/src/contracts/embed-event-payloads.d.ts.map +1 -1
  11. package/cjs/src/contracts/host-event-contracts.d.ts +1 -0
  12. package/cjs/src/contracts/host-event-contracts.d.ts.map +1 -1
  13. package/cjs/src/css-variables.d.ts +140 -0
  14. package/cjs/src/css-variables.d.ts.map +1 -1
  15. package/cjs/src/embed/auto-frame-renderer.d.ts +14 -1
  16. package/cjs/src/embed/auto-frame-renderer.d.ts.map +1 -1
  17. package/cjs/src/embed/auto-frame-renderer.js +91 -2
  18. package/cjs/src/embed/auto-frame-renderer.js.map +1 -1
  19. package/cjs/src/embed/auto-frame-renderer.spec.js +112 -7
  20. package/cjs/src/embed/auto-frame-renderer.spec.js.map +1 -1
  21. package/cjs/src/embed/host-events.spec.js +18 -0
  22. package/cjs/src/embed/host-events.spec.js.map +1 -1
  23. package/cjs/src/embed/liveboard.spec.js +42 -0
  24. package/cjs/src/embed/liveboard.spec.js.map +1 -1
  25. package/cjs/src/react/util.d.ts +8 -1
  26. package/cjs/src/react/util.d.ts.map +1 -1
  27. package/cjs/src/react/util.js.map +1 -1
  28. package/cjs/src/types.d.ts +181 -1
  29. package/cjs/src/types.d.ts.map +1 -1
  30. package/cjs/src/types.js +180 -0
  31. package/cjs/src/types.js.map +1 -1
  32. package/dist/{index-CrBnZBZv.js → index-DQ7r5f1z.js} +1 -1
  33. package/dist/src/auth.d.ts.map +1 -1
  34. package/dist/src/contracts/embed-event-payloads.d.ts +212 -14
  35. package/dist/src/contracts/embed-event-payloads.d.ts.map +1 -1
  36. package/dist/src/contracts/host-event-contracts.d.ts +1 -0
  37. package/dist/src/contracts/host-event-contracts.d.ts.map +1 -1
  38. package/dist/src/css-variables.d.ts +140 -0
  39. package/dist/src/css-variables.d.ts.map +1 -1
  40. package/dist/src/embed/auto-frame-renderer.d.ts +14 -1
  41. package/dist/src/embed/auto-frame-renderer.d.ts.map +1 -1
  42. package/dist/src/react/util.d.ts +8 -1
  43. package/dist/src/react/util.d.ts.map +1 -1
  44. package/dist/src/types.d.ts +181 -1
  45. package/dist/src/types.d.ts.map +1 -1
  46. package/dist/tsembed-react.es.js +230 -9
  47. package/dist/tsembed-react.js +229 -8
  48. package/dist/tsembed.es.js +298 -10
  49. package/dist/tsembed.js +297 -9
  50. package/dist/visual-embed-sdk-react-full.d.ts +534 -13
  51. package/dist/visual-embed-sdk-react.d.ts +531 -11
  52. package/dist/visual-embed-sdk.d.ts +527 -12
  53. package/lib/package.json +1 -1
  54. package/lib/src/auth.d.ts.map +1 -1
  55. package/lib/src/auth.js +25 -6
  56. package/lib/src/auth.js.map +1 -1
  57. package/lib/src/auth.spec.js +40 -8
  58. package/lib/src/auth.spec.js.map +1 -1
  59. package/lib/src/contracts/contracts.spec.js +165 -2
  60. package/lib/src/contracts/contracts.spec.js.map +1 -1
  61. package/lib/src/contracts/embed-event-payloads.d.ts +212 -14
  62. package/lib/src/contracts/embed-event-payloads.d.ts.map +1 -1
  63. package/lib/src/contracts/host-event-contracts.d.ts +1 -0
  64. package/lib/src/contracts/host-event-contracts.d.ts.map +1 -1
  65. package/lib/src/css-variables.d.ts +140 -0
  66. package/lib/src/css-variables.d.ts.map +1 -1
  67. package/lib/src/embed/auto-frame-renderer.d.ts +14 -1
  68. package/lib/src/embed/auto-frame-renderer.d.ts.map +1 -1
  69. package/lib/src/embed/auto-frame-renderer.js +91 -2
  70. package/lib/src/embed/auto-frame-renderer.js.map +1 -1
  71. package/lib/src/embed/auto-frame-renderer.spec.js +113 -8
  72. package/lib/src/embed/auto-frame-renderer.spec.js.map +1 -1
  73. package/lib/src/embed/host-events.spec.js +18 -0
  74. package/lib/src/embed/host-events.spec.js.map +1 -1
  75. package/lib/src/embed/liveboard.spec.js +42 -0
  76. package/lib/src/embed/liveboard.spec.js.map +1 -1
  77. package/lib/src/react/util.d.ts +8 -1
  78. package/lib/src/react/util.d.ts.map +1 -1
  79. package/lib/src/react/util.js.map +1 -1
  80. package/lib/src/types.d.ts +181 -1
  81. package/lib/src/types.d.ts.map +1 -1
  82. package/lib/src/types.js +180 -0
  83. package/lib/src/types.js.map +1 -1
  84. package/lib/src/visual-embed-sdk.d.ts +527 -12
  85. package/package.json +1 -1
  86. package/src/auth.spec.ts +47 -11
  87. package/src/auth.ts +35 -6
  88. package/src/contracts/contracts.spec.ts +182 -3
  89. package/src/contracts/embed-event-payloads.ts +281 -14
  90. package/src/contracts/host-event-contracts.ts +1 -0
  91. package/src/css-variables.ts +168 -0
  92. package/src/embed/auto-frame-renderer.spec.ts +134 -8
  93. package/src/embed/auto-frame-renderer.ts +103 -2
  94. package/src/embed/host-events.spec.ts +24 -0
  95. package/src/embed/liveboard.spec.ts +45 -0
  96. package/src/react/util.ts +13 -1
  97. package/src/types.ts +181 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thoughtspot/visual-embed-sdk",
3
- "version": "1.53.1",
3
+ "version": "1.53.2-beta",
4
4
  "description": "ThoughtSpot Embed SDK",
5
5
  "module": "lib/src/index.js",
6
6
  "main": "dist/tsembed.js",
package/src/auth.spec.ts CHANGED
@@ -475,35 +475,48 @@ describe('Unit test for auth', () => {
475
475
  expect(tokenAuthService.isActiveService).toHaveBeenCalledTimes(1);
476
476
  });
477
477
 
478
- it('should store decoded accessToken in window when SAMLComplete event includes accessToken', async () => {
479
- Object.defineProperty(window, 'location', { value: { href: '', hash: '' } });
480
- global.window.open = jest.fn().mockReturnValue({ closed: false, focus: jest.fn(), close: jest.fn() });
481
-
478
+ const startSamlPopupFlow = async () => {
479
+ Object.defineProperty(window, 'location', {
480
+ value: { href: '', hash: '', origin: 'https://host.app.com' },
481
+ });
482
+ // samlAuthWindow is module state: retire a popup left open by an
483
+ // earlier test so this flow opens (and trusts) a fresh one.
484
+ if (authInstance.samlAuthWindow) {
485
+ (authInstance.samlAuthWindow as any).closed = true;
486
+ }
487
+ const popup = { closed: false, focus: jest.fn(), close: jest.fn() };
488
+ global.window.open = jest.fn().mockReturnValue(popup);
482
489
  (authInstance as any).samlCompletionPromise = null;
483
490
 
484
- let capturedMessageHandler: ((e: any) => void) | null = null;
491
+ let messageHandler: ((e: any) => void) | null = null;
485
492
  jest.spyOn(window, 'addEventListener').mockImplementation((type: any, handler: any) => {
486
493
  if (type === 'message') {
487
- capturedMessageHandler = handler;
494
+ messageHandler = handler;
488
495
  }
489
496
  });
490
-
491
497
  jest.spyOn(tokenAuthService, 'isActiveService')
492
498
  .mockReturnValueOnce(Promise.resolve(false));
493
499
 
494
500
  const authPromise = authInstance.doSamlAuth({ ...embedConfig.doSamlAuthNoRedirect });
495
-
496
501
  await new Promise<void>((resolve) => setTimeout(resolve, 0));
502
+ document.getElementById('ts-auth-btn').click();
503
+ return { popup, authPromise, messageHandler };
504
+ };
497
505
 
498
- expect(capturedMessageHandler).not.toBeNull();
506
+ it('should store decoded accessToken in window when SAMLComplete event includes accessToken', async () => {
507
+ const { popup, authPromise, messageHandler } = await startSamlPopupFlow();
508
+
509
+ expect(decodeURIComponent((window.open as jest.Mock).mock.calls[0][0]))
510
+ .toContain('/v2/#/embed/saml-complete?hostAppOrigin=https%3A%2F%2Fhost.app.com');
499
511
 
500
512
  const accessToken = 'my-access-token';
501
- capturedMessageHandler!({
513
+ messageHandler!({
514
+ origin: new URL(thoughtSpotHost).origin,
515
+ source: popup,
502
516
  data: {
503
517
  type: EmbedEvent.SAMLComplete,
504
518
  accessToken: encodeURIComponent(accessToken),
505
519
  },
506
- source: { close: jest.fn() },
507
520
  });
508
521
 
509
522
  await authPromise;
@@ -512,6 +525,29 @@ describe('Unit test for auth', () => {
512
525
  checkReleaseVersionInBetaInstance.getValueFromWindow('cachedAuthToken'),
513
526
  ).toBe(accessToken);
514
527
  expect(authInstance.loggedInStatus).toBe(true);
528
+ expect(popup.close).toHaveBeenCalled();
529
+ });
530
+
531
+ it('should ignore SAMLComplete from any origin or window other than the SSO popup', async () => {
532
+ const { popup, authPromise, messageHandler } = await startSamlPopupFlow();
533
+ const data = { type: EmbedEvent.SAMLComplete, accessToken: 'attacker-token' };
534
+ const tsOrigin = new URL(thoughtSpotHost).origin;
535
+
536
+ [
537
+ { origin: 'https://evil.com', source: popup, data },
538
+ { origin: tsOrigin, source: { close: jest.fn() }, data },
539
+ { origin: tsOrigin, source: null, data },
540
+ ].forEach((event) => messageHandler!(event));
541
+
542
+ expect(
543
+ checkReleaseVersionInBetaInstance.getValueFromWindow('cachedAuthToken'),
544
+ ).toBeFalsy();
545
+ expect(popup.close).not.toHaveBeenCalled();
546
+
547
+ // The genuine completion still goes through afterwards.
548
+ messageHandler!({ origin: tsOrigin, source: popup, data: { type: EmbedEvent.SAMLComplete } });
549
+ await authPromise;
550
+ expect(popup.close).toHaveBeenCalled();
515
551
  });
516
552
 
517
553
  });
package/src/auth.ts CHANGED
@@ -426,11 +426,18 @@ export const doBasicAuth = async (embedConfig: EmbedConfig): Promise<boolean> =>
426
426
  * @param ssoURL
427
427
  * @param triggerContainer
428
428
  * @param triggerText
429
+ * @param thoughtSpotHost
429
430
  */
430
- async function samlPopupFlow(ssoURL: string, triggerContainer: DOMSelector, triggerText: string) {
431
+ async function samlPopupFlow(
432
+ ssoURL: string,
433
+ triggerContainer: DOMSelector,
434
+ triggerText: string,
435
+ thoughtSpotHost: string,
436
+ ) {
437
+ const thoughtSpotOrigin = new URL(thoughtSpotHost).origin;
431
438
  let popupClosedCheck: NodeJS.Timeout;
432
439
  const openPopup = () => {
433
- if (samlAuthWindow === null || samlAuthWindow.closed) {
440
+ if (!samlAuthWindow || samlAuthWindow.closed) {
434
441
  samlAuthWindow = window.open(
435
442
  ssoURL,
436
443
  '_blank',
@@ -461,7 +468,13 @@ async function samlPopupFlow(ssoURL: string, triggerContainer: DOMSelector, trig
461
468
  }
462
469
  samlCompletionPromise = samlCompletionPromise || new Promise<void>((resolve, reject) => {
463
470
  window.addEventListener('message', (e) => {
464
- if (e.data.type === EmbedEvent.SAMLComplete) {
471
+ // Only the SSO popup we opened, on the ThoughtSpot origin, may
472
+ // complete the flow or hand us a token; anything else could
473
+ // inject an attacker's token into the auth cache.
474
+ if (e.origin !== thoughtSpotOrigin || !samlAuthWindow || e.source !== samlAuthWindow) {
475
+ return;
476
+ }
477
+ if (e.data?.type === EmbedEvent.SAMLComplete) {
465
478
  if (e.data.accessToken) {
466
479
  const decodedToken = decodeURIComponent(e.data.accessToken);
467
480
  storeAuthTokenInCache(decodedToken);
@@ -506,7 +519,12 @@ const doSSOAuth = async (embedConfig: EmbedConfig, ssoEndPoint: string): Promise
506
519
 
507
520
  const ssoURL = `${thoughtSpotHost}${ssoEndPoint}`;
508
521
  if (embedConfig.inPopup) {
509
- await samlPopupFlow(ssoURL, embedConfig.authTriggerContainer, embedConfig.authTriggerText);
522
+ await samlPopupFlow(
523
+ ssoURL,
524
+ embedConfig.authTriggerContainer,
525
+ embedConfig.authTriggerText,
526
+ thoughtSpotHost,
527
+ );
510
528
  const cachedToken = getCacheAuthToken();
511
529
  if (cachedToken) {
512
530
  loggedInStatus = true;
@@ -519,12 +537,23 @@ const doSSOAuth = async (embedConfig: EmbedConfig, ssoEndPoint: string): Promise
519
537
  window.location.href = ssoURL;
520
538
  };
521
539
 
540
+ /**
541
+ * The popup's SSO completion page. hostAppOrigin tells ThoughtSpot which
542
+ * origin opened the popup; the cluster only posts a token back to it if it
543
+ * is an allowed redirect domain.
544
+ * @param thoughtSpotHost
545
+ */
546
+ const getSSOCompletePageUrl = (thoughtSpotHost: string): string => {
547
+ const hostAppOrigin = encodeURIComponent(window.location.origin);
548
+ return `${thoughtSpotHost}/v2/#/embed/saml-complete?hostAppOrigin=${hostAppOrigin}`;
549
+ };
550
+
522
551
  export const doSamlAuth = async (embedConfig: EmbedConfig) => {
523
552
  const { thoughtSpotHost } = embedConfig;
524
553
  // redirect for SSO, when the SSO authentication is done, this page will be
525
554
  // loaded again and the same JS will execute again.
526
555
  const ssoRedirectUrl = embedConfig.inPopup
527
- ? `${thoughtSpotHost}/v2/#/embed/saml-complete`
556
+ ? getSSOCompletePageUrl(thoughtSpotHost)
528
557
  : getRedirectUrl(
529
558
  window.location.href,
530
559
  SSO_REDIRECTION_MARKER_GUID,
@@ -543,7 +572,7 @@ export const doOIDCAuth = async (embedConfig: EmbedConfig) => {
543
572
  // redirect for SSO, when the SSO authentication is done, this page will be
544
573
  // loaded again and the same JS will execute again.
545
574
  const ssoRedirectUrl = embedConfig.noRedirect || embedConfig.inPopup
546
- ? `${thoughtSpotHost}/v2/#/embed/saml-complete`
575
+ ? getSSOCompletePageUrl(thoughtSpotHost)
547
576
  : getRedirectUrl(
548
577
  window.location.href,
549
578
  SSO_REDIRECTION_MARKER_GUID,
@@ -15,6 +15,8 @@
15
15
  * stop — that breaks published SDK consumers and the host runtime validation
16
16
  * derived from these contracts.
17
17
  */
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
18
20
  import { HostEvent, EmbedEvent, RuntimeFilterOp } from '../types';
19
21
  import { UIPassthroughEvent } from './ui-passthrough-contracts';
20
22
  import type {
@@ -234,17 +236,121 @@ describe('event contracts (drift guardrails)', () => {
234
236
  );
235
237
  });
236
238
 
237
- test('embed event enum wire values referenced by typed payloads are stable', () => {
239
+ test('typed embed event wire values are stable (additive-only)', () => {
238
240
  expect(
239
241
  [
242
+ EmbedEvent.AIHighlights,
243
+ EmbedEvent.AddRemoveColumns,
244
+ EmbedEvent.AddToCoaching,
245
+ EmbedEvent.AddToFavorites,
246
+ EmbedEvent.AnswerChartSwitcher,
247
+ EmbedEvent.AnswerDelete,
240
248
  EmbedEvent.AuthInit,
241
- EmbedEvent.EmbedListenerReady,
249
+ EmbedEvent.Cancel,
250
+ EmbedEvent.CopyLink,
251
+ EmbedEvent.CopyToClipboard,
252
+ EmbedEvent.CrossFilterChanged,
242
253
  EmbedEvent.CustomAction,
254
+ EmbedEvent.Data,
255
+ EmbedEvent.DataSourceSelected,
256
+ EmbedEvent.Delete,
257
+ EmbedEvent.DialogClose,
258
+ EmbedEvent.DialogOpen,
259
+ EmbedEvent.Download,
260
+ EmbedEvent.DownloadAsCsv,
261
+ EmbedEvent.DownloadAsPdf,
262
+ EmbedEvent.DownloadAsPng,
263
+ EmbedEvent.DownloadAsXlsx,
264
+ EmbedEvent.DownloadLiveboardAsContinuousPDF,
265
+ EmbedEvent.Drilldown,
266
+ EmbedEvent.Edit,
267
+ EmbedEvent.EditTML,
268
+ EmbedEvent.EmbedIframeCenter,
269
+ EmbedEvent.EmbedListenerReady,
270
+ EmbedEvent.Error,
271
+ EmbedEvent.ExitPresentMode,
272
+ EmbedEvent.Explore,
273
+ EmbedEvent.ExportTML,
274
+ EmbedEvent.FilterChanged,
275
+ EmbedEvent.LiveboardInfo,
276
+ EmbedEvent.LiveboardRendered,
277
+ EmbedEvent.MakeACopy,
278
+ EmbedEvent.ParameterChanged,
279
+ EmbedEvent.Pin,
280
+ EmbedEvent.Present,
281
+ EmbedEvent.QueryChanged,
282
+ EmbedEvent.Rename,
283
+ EmbedEvent.RequestVisibleEmbedCoordinates,
284
+ EmbedEvent.Save,
285
+ EmbedEvent.SavePersonalisedView,
286
+ EmbedEvent.Schedule,
287
+ EmbedEvent.SchedulesList,
288
+ EmbedEvent.SendTestScheduleEmail,
289
+ EmbedEvent.ShowUnderlyingData,
290
+ EmbedEvent.SpotIQAnalyze,
291
+ EmbedEvent.TableVizRendered,
292
+ EmbedEvent.UpdatePersonalisedView,
293
+ EmbedEvent.UpdateTML,
294
+ EmbedEvent.VizPointClick,
295
+ EmbedEvent.VizPointDoubleClick,
296
+ EmbedEvent.VizPointRightClick,
243
297
  ].map((e) => `${e}`).sort(),
244
298
  ).toEqual([
299
+ 'AIHighlights',
300
+ 'EmbedIframeCenter',
245
301
  'EmbedListenerReady',
302
+ 'Error',
303
+ 'PinboardRendered',
304
+ 'TableVizRendered',
305
+ 'addRemoveColumns',
306
+ 'addToCoaching',
307
+ 'addToFavorites',
308
+ 'answerChartSwitcher',
309
+ 'answerDelete',
246
310
  'authInit',
311
+ 'cancel',
312
+ 'context-menu-item-copy-to-clipboard',
313
+ 'cross-filter-changed',
247
314
  'customAction',
315
+ 'data',
316
+ 'dataSourceSelected',
317
+ 'delete',
318
+ 'dialog-close',
319
+ 'dialog-open',
320
+ 'download',
321
+ 'downloadAsCsv',
322
+ 'downloadAsPdf',
323
+ 'downloadAsPng',
324
+ 'downloadAsXlsx',
325
+ 'downloadLiveboardAsContinuousPDF',
326
+ 'drillDown',
327
+ 'edit',
328
+ 'editTSL',
329
+ 'embedDocument',
330
+ 'exitPresentMode',
331
+ 'explore',
332
+ 'exportTSL',
333
+ 'filterChanged',
334
+ 'makeACopy',
335
+ 'parameterChanged',
336
+ 'pin',
337
+ 'pinboardInfo',
338
+ 'present',
339
+ 'queryChanged',
340
+ 'rename',
341
+ 'requestVisibleEmbedCoordinates',
342
+ 'save',
343
+ 'savePersonalisedView',
344
+ 'schedule-list',
345
+ 'sendTestScheduleEmail',
346
+ 'showUnderlyingData',
347
+ 'spotIQAnalyze',
348
+ 'subscription',
349
+ 'updatePersonalisedView',
350
+ 'updateTSL',
351
+ 'vizPointClick',
352
+ 'vizPointDoubleClick',
353
+ 'vizPointRightClick',
248
354
  ]);
249
355
  });
250
356
 
@@ -262,7 +368,7 @@ describe('event contracts (drift guardrails)', () => {
262
368
  const svc: AnswerServiceField = undefined;
263
369
  expect(svc).toBeUndefined();
264
370
  // Untyped embed event stays `any` (backward compatible).
265
- const untyped: EmbedEventData<EmbedEvent.Data> = { anything: 'goes' };
371
+ const untyped: EmbedEventData<EmbedEvent.RouteChange> = { anything: 'goes' };
266
372
  expect(untyped).toBeDefined();
267
373
  });
268
374
 
@@ -393,4 +499,77 @@ describe('event contracts (drift guardrails)', () => {
393
499
  const all = [valid, empty, loose, topExtra, nestedExtra, zeroOverlap, bad];
394
500
  expect([...all, voidEmpty, unmapped].every(Boolean)).toBe(true);
395
501
  });
502
+
503
+ // Doc <-> type drift guard. Event enum members document their payload with a
504
+ // `Payload: {@link XxxRequest}` / `{@link XxxPayload}` TypeDoc link. TypeScript
505
+ // does NOT validate {@link} targets, so documenting an event's payload without
506
+ // declaring the type ships a dangling doc link and an untyped event. This test
507
+ // fails in exactly that case: every payload link in types.ts must resolve to a
508
+ // real declared contract type.
509
+ test('event doc payload links resolve to a declared type (docs and types must agree)', () => {
510
+ const contractsDir = __dirname;
511
+ const sources = [
512
+ fs.readFileSync(path.join(contractsDir, '../types.ts'), 'utf8'),
513
+ ...fs
514
+ .readdirSync(contractsDir)
515
+ .filter((f) => f.endsWith('.ts') && !f.endsWith('.spec.ts'))
516
+ .map((f) => fs.readFileSync(path.join(contractsDir, f), 'utf8')),
517
+ ];
518
+
519
+ // Every type/interface/enum/class declared across types.ts + contracts.
520
+ const declared = new Set<string>();
521
+ const declRe = /(?:export\s+)?(?:declare\s+)?(?:abstract\s+)?(?:interface|type|enum|class)\s+(\w+)/g;
522
+ for (const src of sources) {
523
+ for (const m of src.matchAll(declRe)) declared.add(m[1]);
524
+ }
525
+
526
+ // Payload-shaped {@link} references in the event docs (types.ts).
527
+ const typesSrc = sources[0];
528
+ const linkRe = /\{@link\s+(\w+(?:Request|Payload|Scope))\b[^}]*\}/g;
529
+ const dangling = new Set<string>();
530
+ for (const m of typesSrc.matchAll(linkRe)) {
531
+ if (!declared.has(m[1])) dangling.add(m[1]);
532
+ }
533
+
534
+ // If this fails, a doc links a payload type that does not exist — add the
535
+ // type (and map it), or fix the link.
536
+ expect([...dangling]).toEqual([]);
537
+ });
538
+
539
+ // Doc drives the type: if an event's TypeDoc documents a payload with a
540
+ // `Payload: {@link X}` link, that event MUST be typed in EmbedEventDataExtension.
541
+ // Otherwise the published doc promises a payload type while `on()` still hands
542
+ // back `any`. Adding the doc link is the trigger; this fails until the event is
543
+ // added to the map.
544
+ test('every embed event with a Payload {@link} is typed in the map', () => {
545
+ const typesSrc = fs.readFileSync(
546
+ path.join(__dirname, '..', 'types.ts'), 'utf8',
547
+ );
548
+ const payloadsSrc = fs.readFileSync(
549
+ path.join(__dirname, 'embed-event-payloads.ts'), 'utf8',
550
+ );
551
+
552
+ // Events typed in the map.
553
+ const mapBody = payloadsSrc.match(
554
+ /EmbedEventDataExtension\s*\{([\s\S]*?)\n\}/,
555
+ )?.[1] ?? '';
556
+ const typedMembers = new Set(
557
+ [...mapBody.matchAll(/\[EmbedEvent\.(\w+)\]/g)].map((m) => m[1]),
558
+ );
559
+
560
+ // EmbedEvent members whose doc carries a `Payload: {@link ...}` link.
561
+ const enumBody = typesSrc.match(
562
+ /export enum EmbedEvent\s*\{([\s\S]*?)\n\}/,
563
+ )?.[1] ?? '';
564
+ const documentedWithLink: string[] = [];
565
+ for (const m of enumBody.matchAll(/\/\*\*([\s\S]*?)\*\/\s*(\w+)\s*=/g)) {
566
+ if (/Payload:\s*\{@link/.test(m[1])) documentedWithLink.push(m[2]);
567
+ }
568
+
569
+ // Documented a payload type but not mapped -> on() still returns `any`.
570
+ const documentedButUntyped = documentedWithLink.filter(
571
+ (name) => !typedMembers.has(name),
572
+ );
573
+ expect(documentedButUntyped).toEqual([]);
574
+ });
396
575
  });