@assistant-ui/react-generative-ui 0.0.15 → 0.0.16

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 (60) hide show
  1. package/dist/convert/boundSpec.d.ts +47 -0
  2. package/dist/convert/boundSpec.d.ts.map +1 -0
  3. package/dist/convert/boundSpec.js +99 -0
  4. package/dist/convert/boundSpec.js.map +1 -0
  5. package/dist/convert/copyBounded.d.ts +28 -0
  6. package/dist/convert/copyBounded.d.ts.map +1 -0
  7. package/dist/convert/copyBounded.js +38 -0
  8. package/dist/convert/copyBounded.js.map +1 -0
  9. package/dist/convert/isElement.d.ts +6 -0
  10. package/dist/convert/isElement.d.ts.map +1 -0
  11. package/dist/convert/isElement.js +6 -0
  12. package/dist/convert/isElement.js.map +1 -0
  13. package/dist/convert/takeRun.d.ts +9 -0
  14. package/dist/convert/takeRun.d.ts.map +1 -0
  15. package/dist/convert/takeRun.js +20 -0
  16. package/dist/convert/takeRun.js.map +1 -0
  17. package/dist/slack/constants.d.ts +1 -26
  18. package/dist/slack/constants.d.ts.map +1 -1
  19. package/dist/slack/constants.js +1 -26
  20. package/dist/slack/constants.js.map +1 -1
  21. package/dist/slack/fromSlackBlocks.js +6 -10
  22. package/dist/slack/fromSlackBlocks.js.map +1 -1
  23. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  24. package/dist/slack/toSlackBlocks.js +28 -95
  25. package/dist/slack/toSlackBlocks.js.map +1 -1
  26. package/dist/teams/constants.d.ts +1 -28
  27. package/dist/teams/constants.d.ts.map +1 -1
  28. package/dist/teams/constants.js +1 -32
  29. package/dist/teams/constants.js.map +1 -1
  30. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  31. package/dist/teams/toAdaptiveCard.js +12 -23
  32. package/dist/teams/toAdaptiveCard.js.map +1 -1
  33. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  34. package/dist/teams/toTeamsAttachments.js +3 -4
  35. package/dist/teams/toTeamsAttachments.js.map +1 -1
  36. package/dist/vocabulary/list.js +4 -2
  37. package/dist/vocabulary/list.js.map +1 -1
  38. package/package.json +6 -6
  39. package/src/convert/boundSpec.test.ts +147 -0
  40. package/src/{teams → convert}/boundSpec.ts +48 -14
  41. package/src/convert/copyBounded.test.ts +131 -0
  42. package/src/convert/copyBounded.ts +38 -0
  43. package/src/convert/isElement.ts +6 -0
  44. package/src/convert/takeRun.test.ts +64 -0
  45. package/src/convert/takeRun.ts +19 -0
  46. package/src/slack/constants.ts +0 -29
  47. package/src/slack/fromSlackBlocks.test.ts +105 -7
  48. package/src/slack/fromSlackBlocks.ts +9 -9
  49. package/src/slack/toSlackBlocks.test.ts +82 -2
  50. package/src/slack/toSlackBlocks.ts +68 -164
  51. package/src/teams/constants.ts +0 -42
  52. package/src/teams/toAdaptiveCard.test.ts +74 -2
  53. package/src/teams/toAdaptiveCard.ts +29 -36
  54. package/src/teams/toTeamsAttachments.ts +2 -8
  55. package/src/vocabulary/list.test.tsx +35 -9
  56. package/src/vocabulary/list.tsx +4 -2
  57. package/dist/teams/boundSpec.d.ts +0 -20
  58. package/dist/teams/boundSpec.d.ts.map +0 -1
  59. package/dist/teams/boundSpec.js +0 -67
  60. package/dist/teams/boundSpec.js.map +0 -1
@@ -1,3 +1,11 @@
1
+ import {
2
+ MAX_TRAVERSAL_DEPTH,
3
+ boundSpec,
4
+ clampReasonDetail,
5
+ } from "../convert/boundSpec";
6
+ import { copyBounded } from "../convert/copyBounded";
7
+ import { isElement } from "../convert/isElement";
8
+ import { takeRun } from "../convert/takeRun";
1
9
  import {
2
10
  normalizeSpec,
3
11
  type NormalizedUIElement,
@@ -14,7 +22,6 @@ import {
14
22
  CARD_TITLE_CAP,
15
23
  CAROUSEL_CARD_CAP,
16
24
  CAROUSEL_CARD_MIN,
17
- CHILDREN_CAP,
18
25
  CONTEXT_ELEMENT_CAP,
19
26
  CONTEXT_TEXT_CAP,
20
27
  DATA_TABLE_CHAR_BUDGET,
@@ -26,11 +33,8 @@ import {
26
33
  INPUT_LABEL_CAP,
27
34
  INTERACTIVE_TEXT_CAP,
28
35
  MARKDOWN_TEXT_BUDGET,
29
- MAX_ELEMENT_DEPTH,
30
- MAX_TRAVERSAL_DEPTH,
31
36
  MESSAGE_BLOCK_CAP,
32
37
  MODAL_BLOCK_CAP,
33
- NODE_BUDGET,
34
38
  PLACEHOLDER_TEXT_CAP,
35
39
  RADIO_OPTION_CAP,
36
40
  SECTION_TEXT_CAP,
@@ -87,9 +91,6 @@ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
87
91
  const isRecord = (value: unknown): value is Record<string, unknown> =>
88
92
  typeof value === "object" && value !== null && !Array.isArray(value);
89
93
 
90
- const isElement = (node: NormalizedUINode): node is NormalizedUIElement =>
91
- isRecord(node);
92
-
93
94
  const asString = (value: unknown): string =>
94
95
  typeof value === "string" ? value : "";
95
96
 
@@ -244,7 +245,11 @@ const toActionElement = (
244
245
  const rawOptions = Array.isArray(props["options"])
245
246
  ? props["options"]
246
247
  : [];
247
- if (rawOptions.length > SELECT_OPTION_CAP) {
248
+ const { items: takenOptions, truncated } = copyBounded(
249
+ rawOptions,
250
+ SELECT_OPTION_CAP,
251
+ );
252
+ if (truncated) {
248
253
  warn(
249
254
  context,
250
255
  "clamped",
@@ -252,7 +257,6 @@ const toActionElement = (
252
257
  `options were clamped to ${SELECT_OPTION_CAP} entries.`,
253
258
  );
254
259
  }
255
- const takenOptions = rawOptions.slice(0, SELECT_OPTION_CAP);
256
260
  const options = takenOptions
257
261
  .map((option) => optionFrom(option, "Select", context))
258
262
  .filter((option): option is SlackOption => option !== undefined);
@@ -319,7 +323,11 @@ const toActionElement = (
319
323
  const rawOptions = Array.isArray(props["options"])
320
324
  ? props["options"]
321
325
  : [];
322
- if (rawOptions.length > RADIO_OPTION_CAP) {
326
+ const { items: takenOptions, truncated } = copyBounded(
327
+ rawOptions,
328
+ RADIO_OPTION_CAP,
329
+ );
330
+ if (truncated) {
323
331
  warn(
324
332
  context,
325
333
  "clamped",
@@ -327,7 +335,6 @@ const toActionElement = (
327
335
  `options were clamped to ${RADIO_OPTION_CAP} entries.`,
328
336
  );
329
337
  }
330
- const takenOptions = rawOptions.slice(0, RADIO_OPTION_CAP);
331
338
  const options = takenOptions
332
339
  .map((option) => optionFrom(option, "RadioGroup", context))
333
340
  .filter((option): option is SlackOption => option !== undefined);
@@ -548,7 +555,11 @@ const assembleCleanCard = (
548
555
  ),
549
556
  }
550
557
  : undefined;
551
- if (buttons.length > CARD_ACTIONS_CAP) {
558
+ const { items: boundedButtons, truncated } = copyBounded(
559
+ buttons,
560
+ CARD_ACTIONS_CAP,
561
+ );
562
+ if (truncated) {
552
563
  warn(
553
564
  context,
554
565
  "clamped",
@@ -563,9 +574,7 @@ const assembleCleanCard = (
563
574
  : {}),
564
575
  ...(body !== undefined ? { body } : {}),
565
576
  ...(subtext !== undefined ? { subtext } : {}),
566
- ...(buttons.length > 0
567
- ? { actions: buttons.slice(0, CARD_ACTIONS_CAP) }
568
- : {}),
577
+ ...(boundedButtons.length > 0 ? { actions: boundedButtons } : {}),
569
578
  });
570
579
  };
571
580
 
@@ -758,7 +767,11 @@ const contextRow = (
758
767
  if (allChildren.length === 0 || children.length !== allChildren.length) {
759
768
  return undefined;
760
769
  }
761
- if (children.length > CONTEXT_ELEMENT_CAP) {
770
+ const { items: boundedChildren, truncated } = copyBounded(
771
+ children,
772
+ CONTEXT_ELEMENT_CAP,
773
+ );
774
+ if (truncated) {
762
775
  warn(
763
776
  context,
764
777
  "clamped",
@@ -769,7 +782,7 @@ const contextRow = (
769
782
  return [
770
783
  {
771
784
  type: "context",
772
- elements: children.slice(0, CONTEXT_ELEMENT_CAP).map((child) => ({
785
+ elements: boundedChildren.map((child) => ({
773
786
  type: "mrkdwn",
774
787
  text: clampText(
775
788
  asString(child.props["value"]),
@@ -874,7 +887,11 @@ const convertCarousel = (
874
887
  `${droppedCards} non-card ${droppedCards === 1 ? "child was" : "children were"} dropped.`,
875
888
  );
876
889
  }
877
- if (cardChildren.length > CAROUSEL_CARD_CAP) {
890
+ const { items: boundedCards, truncated } = copyBounded(
891
+ cardChildren,
892
+ CAROUSEL_CARD_CAP,
893
+ );
894
+ if (truncated) {
878
895
  warn(
879
896
  context,
880
897
  "clamped",
@@ -882,8 +899,7 @@ const convertCarousel = (
882
899
  `cards were clamped to ${CAROUSEL_CARD_CAP} entries.`,
883
900
  );
884
901
  }
885
- const cards = cardChildren
886
- .slice(0, CAROUSEL_CARD_CAP)
902
+ const cards = boundedCards
887
903
  .map((card) => convertCarouselCard(card, context, depth + 1))
888
904
  .filter((card): card is SlackCardBlock => card !== undefined);
889
905
  if (cards.length < CAROUSEL_CARD_MIN) {
@@ -921,7 +937,15 @@ const convertTable = (
921
937
  const rawRows = Array.isArray(element.props["rows"])
922
938
  ? element.props["rows"]
923
939
  : [];
924
- if (rawColumns.length > DATA_TABLE_COLUMN_CAP) {
940
+ const { items: takenColumns, truncated: columnsTruncated } = copyBounded(
941
+ rawColumns,
942
+ DATA_TABLE_COLUMN_CAP,
943
+ );
944
+ const { items: takenRows, truncated: rowsTruncated } = copyBounded(
945
+ rawRows,
946
+ DATA_TABLE_ROW_CAP,
947
+ );
948
+ if (columnsTruncated) {
925
949
  warn(
926
950
  context,
927
951
  "clamped",
@@ -929,7 +953,7 @@ const convertTable = (
929
953
  `columns were clamped to ${DATA_TABLE_COLUMN_CAP} entries.`,
930
954
  );
931
955
  }
932
- if (rawRows.length > DATA_TABLE_ROW_CAP) {
956
+ if (rowsTruncated) {
933
957
  warn(
934
958
  context,
935
959
  "clamped",
@@ -938,7 +962,6 @@ const convertTable = (
938
962
  );
939
963
  }
940
964
 
941
- const takenColumns = rawColumns.slice(0, DATA_TABLE_COLUMN_CAP);
942
965
  const unlabeled = takenColumns.filter(
943
966
  (column) => !isRecord(column) || typeof column["label"] !== "string",
944
967
  ).length;
@@ -954,16 +977,15 @@ const convertTable = (
954
977
  type: "raw_text" as const,
955
978
  text: isRecord(column) ? asString(column["label"]) : "",
956
979
  }));
957
- const dataRows: SlackDataTableCell[][] = rawRows
958
- .slice(0, DATA_TABLE_ROW_CAP)
959
- .map((row) =>
960
- (Array.isArray(row) ? row : [])
961
- .slice(0, DATA_TABLE_COLUMN_CAP)
962
- .map(
963
- (value) =>
964
- toDataTableCell(value) ?? { type: "raw_text" as const, text: "" },
965
- ),
966
- );
980
+ const dataRows: SlackDataTableCell[][] = takenRows.map((row) =>
981
+ (Array.isArray(row)
982
+ ? copyBounded(row, DATA_TABLE_COLUMN_CAP).items
983
+ : []
984
+ ).map(
985
+ (value) =>
986
+ toDataTableCell(value) ?? { type: "raw_text" as const, text: "" },
987
+ ),
988
+ );
967
989
  const width = Math.max(
968
990
  columnHeaderRow.length,
969
991
  0,
@@ -1265,32 +1287,20 @@ function convertSequence(
1265
1287
  continue;
1266
1288
  }
1267
1289
  if (isElement(current) && current.type === "Fact") {
1268
- const facts: NormalizedUIElement[] = [];
1269
- while (index < nodes.length) {
1270
- const candidate = nodes[index];
1271
- if (!candidate || !isElement(candidate) || candidate.type !== "Fact") {
1272
- break;
1273
- }
1274
- facts.push(candidate);
1275
- index += 1;
1276
- }
1290
+ const { run: facts, next } = takeRun(
1291
+ nodes,
1292
+ index,
1293
+ (candidate) => candidate.type === "Fact",
1294
+ );
1295
+ index = next;
1277
1296
  blocks.push(...convertFacts(facts, context));
1278
1297
  continue;
1279
1298
  }
1280
1299
  if (isElement(current) && INTERACTIVE_TYPES.has(current.type)) {
1281
- const controls: NormalizedUIElement[] = [];
1282
- while (index < nodes.length) {
1283
- const candidate = nodes[index];
1284
- if (
1285
- !candidate ||
1286
- !isElement(candidate) ||
1287
- !INTERACTIVE_TYPES.has(candidate.type)
1288
- ) {
1289
- break;
1290
- }
1291
- controls.push(candidate);
1292
- index += 1;
1293
- }
1300
+ const { run: controls, next } = takeRun(nodes, index, (candidate) =>
1301
+ INTERACTIVE_TYPES.has(candidate.type),
1302
+ );
1303
+ index = next;
1294
1304
  blocks.push(...convertActions(controls, context));
1295
1305
  continue;
1296
1306
  }
@@ -1320,104 +1330,6 @@ function convertSequence(
1320
1330
  return blocks;
1321
1331
  }
1322
1332
 
1323
- interface BoundState {
1324
- remaining: number;
1325
- exhausted: boolean;
1326
- }
1327
-
1328
- type ClampReason = "children" | "budget" | "cycle" | "depth";
1329
-
1330
- function boundNode(
1331
- value: unknown,
1332
- depth: number,
1333
- onClamp: (reason: ClampReason) => void,
1334
- state: BoundState,
1335
- ancestors: WeakSet<object>,
1336
- ): unknown {
1337
- if (state.remaining <= 0) {
1338
- if (!state.exhausted) {
1339
- state.exhausted = true;
1340
- onClamp("budget");
1341
- }
1342
- return null;
1343
- }
1344
- state.remaining -= 1;
1345
- if (depth > MAX_TRAVERSAL_DEPTH) {
1346
- onClamp("depth");
1347
- return null;
1348
- }
1349
- if (Array.isArray(value)) {
1350
- if (ancestors.has(value)) {
1351
- onClamp("cycle");
1352
- return null;
1353
- }
1354
- ancestors.add(value);
1355
- const bounded = Array.prototype.slice.call(
1356
- value,
1357
- 0,
1358
- CHILDREN_CAP,
1359
- ) as unknown[];
1360
- if (value.length > CHILDREN_CAP) onClamp("children");
1361
- const result = bounded.map((item) =>
1362
- boundNode(item, depth + 1, onClamp, state, ancestors),
1363
- );
1364
- ancestors.delete(value);
1365
- return result;
1366
- }
1367
- if (
1368
- value !== null &&
1369
- typeof value === "object" &&
1370
- "children" in (value as Record<string, unknown>)
1371
- ) {
1372
- if (ancestors.has(value)) {
1373
- onClamp("cycle");
1374
- return null;
1375
- }
1376
- ancestors.add(value);
1377
- const record = value as Record<string, unknown>;
1378
- const result = {
1379
- ...record,
1380
- children: boundNode(
1381
- record["children"],
1382
- depth + 1,
1383
- onClamp,
1384
- state,
1385
- ancestors,
1386
- ),
1387
- };
1388
- ancestors.delete(value);
1389
- return result;
1390
- }
1391
- return value;
1392
- }
1393
-
1394
- /**
1395
- * Produces a bounded plain copy of a raw generative-ui spec before it
1396
- * reaches `normalizeSpec`, whose own traversal of the root array or any
1397
- * `children` array walks the full reported length of a hostile proxied
1398
- * array before any per-field cap downstream ever applies. Every array
1399
- * (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}
1400
- * entries via `Array.prototype.slice`, which bounds even a proxy with a
1401
- * fabricated `length`; recursion itself is capped at
1402
- * {@link MAX_TRAVERSAL_DEPTH}. `onClamp` fires once per level that was
1403
- * truncated, receiving the reason for that truncation: `"children"`,
1404
- * `"depth"`, `"budget"`, or `"cycle"`. The walk also spends a total budget of
1405
- * {@link NODE_BUDGET} nodes, so shared references cannot multiply work
1406
- * exponentially, and a node that is its own ancestor is cut to `null`.
1407
- */
1408
- function boundSpec(
1409
- spec: unknown,
1410
- onClamp: (reason: ClampReason) => void,
1411
- ): unknown {
1412
- return boundNode(
1413
- spec,
1414
- 0,
1415
- onClamp,
1416
- { remaining: NODE_BUDGET, exhausted: false },
1417
- new WeakSet(),
1418
- );
1419
- }
1420
-
1421
1333
  /** Converts a generative-UI tree into Slack Block Kit JSON and downgrade warnings. */
1422
1334
  export function toSlackBlocks(
1423
1335
  node: unknown,
@@ -1433,17 +1345,9 @@ export function toSlackBlocks(
1433
1345
  dataTableCharacters: 0,
1434
1346
  };
1435
1347
  try {
1436
- const bounded = boundSpec(node, (reason) => {
1437
- const detail =
1438
- reason === "budget"
1439
- ? `the tree was truncated after ${NODE_BUDGET} nodes.`
1440
- : reason === "cycle"
1441
- ? "a self-referencing node was dropped."
1442
- : reason === "depth"
1443
- ? `nodes deeper than ${MAX_ELEMENT_DEPTH} levels were dropped.`
1444
- : `children were clamped to ${CHILDREN_CAP} entries.`;
1445
- warn(context, "clamped", "Root", detail);
1446
- });
1348
+ const bounded = boundSpec(node, (reason) =>
1349
+ warn(context, "clamped", "Root", clampReasonDetail(reason)),
1350
+ );
1447
1351
  const { root } = normalizeSpec(bounded as never);
1448
1352
  const converted = convertSequence(root, context, 0);
1449
1353
  if (converted.length <= context.blockCap) {
@@ -17,48 +17,6 @@ export const ADAPTIVE_CARD_SCHEMA =
17
17
  export const ATTACHMENT_CONTENT_TYPE =
18
18
  "application/vnd.microsoft.card.adaptive";
19
19
 
20
- /** The maximum traversal depth, counted in visited values, not tree levels. */
21
- export const MAX_TRAVERSAL_DEPTH = 64;
22
-
23
- /**
24
- * The element-nesting ceiling a caller observes. `boundNode` visits an element
25
- * and its `children` array separately, so one level of nesting spends two of
26
- * {@link MAX_TRAVERSAL_DEPTH}; it runs before the conversion walk and is never
27
- * the looser of the two. A root array costs one more level than a root object.
28
- */
29
- export const MAX_ELEMENT_DEPTH = MAX_TRAVERSAL_DEPTH / 2;
30
-
31
- /**
32
- * The maximum number of entries kept at any single level (root, or a
33
- * `children` array at any depth) of the raw spec, before it ever reaches
34
- * `normalizeSpec`. Bounds a hostile array's reported `length` so the pre-pass
35
- * can never be made to walk further than this regardless of what the array
36
- * claims about itself.
37
- */
38
- export const CHILDREN_CAP = 200;
39
-
40
- /**
41
- * The total number of nodes {@link boundSpec} will visit across one walk,
42
- * regardless of how many times a shared reference recurs. Bounds the
43
- * combinatorial work a DAG of shared or self-referential nodes would
44
- * otherwise force even though each individual array stays within
45
- * {@link CHILDREN_CAP}.
46
- */
47
- export const NODE_BUDGET = 5000;
48
-
49
- export type ClampReason = "children" | "budget" | "cycle" | "depth";
50
-
51
- export function clampReasonDetail(reason: ClampReason): string {
52
- if (reason === "budget") {
53
- return `the tree was truncated after ${NODE_BUDGET} nodes.`;
54
- }
55
- if (reason === "cycle") return "a self-referencing node was dropped.";
56
- if (reason === "depth") {
57
- return `nodes deeper than ${MAX_ELEMENT_DEPTH} levels were dropped.`;
58
- }
59
- return `children were clamped to ${CHILDREN_CAP} entries.`;
60
- }
61
-
62
20
  /** The maximum number of attachments produced from a root-level Carousel. */
63
21
  export const CAROUSEL_ATTACHMENT_CAP = 10;
64
22
 
@@ -1,10 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { CHILDREN_CAP, NODE_BUDGET } from "../convert/boundSpec";
2
3
  import { toAdaptiveCard } from "./toAdaptiveCard";
3
4
  import { toTeamsAttachments } from "./toTeamsAttachments";
4
5
  import {
5
6
  CAROUSEL_ATTACHMENT_CAP,
6
- CHILDREN_CAP,
7
- NODE_BUDGET,
7
+ CHOICE_OPTION_CAP,
8
8
  PAYLOAD_SOFT_CAP,
9
9
  PRIMARY_ACTION_CAP,
10
10
  TABLE_COLUMN_CAP,
@@ -25,6 +25,32 @@ import type {
25
25
  TeamsTextBlock,
26
26
  } from "./types";
27
27
 
28
+ const hostileSpecies = <T>(
29
+ source: T[],
30
+ injected: unknown[],
31
+ onDispatch: () => void,
32
+ ): T[] => {
33
+ function HostileCtor() {
34
+ return {
35
+ map: () => {
36
+ onDispatch();
37
+ return injected;
38
+ },
39
+ [Symbol.iterator]: function* () {
40
+ onDispatch();
41
+ yield* injected;
42
+ },
43
+ };
44
+ }
45
+ const constructor = { [Symbol.species]: HostileCtor };
46
+ return new Proxy(source, {
47
+ get: (target, prop, receiver) =>
48
+ prop === "constructor"
49
+ ? constructor
50
+ : Reflect.get(target, prop, receiver),
51
+ });
52
+ };
53
+
28
54
  describe("toAdaptiveCard", () => {
29
55
  describe("Header", () => {
30
56
  it("renders a heading TextBlock", () => {
@@ -455,6 +481,29 @@ describe("toAdaptiveCard", () => {
455
481
  expect((card.body[0] as TeamsInputChoiceSet).id).toBe("select");
456
482
  });
457
483
 
484
+ it("does not dispatch a prop array through Symbol.species", () => {
485
+ let dispatches = 0;
486
+ const injected = Array.from(
487
+ { length: CHOICE_OPTION_CAP + 1 },
488
+ (_, i) => ({ label: `injected-${i}`, value: `injected-${i}` }),
489
+ );
490
+ const { card, warnings } = toAdaptiveCard({
491
+ $type: "Select",
492
+ options: hostileSpecies(
493
+ [{ label: "Kept", value: "kept" }],
494
+ injected,
495
+ () => {
496
+ dispatches += 1;
497
+ },
498
+ ),
499
+ });
500
+ expect((card.body[0] as TeamsInputChoiceSet).choices).toEqual([
501
+ { title: "Kept", value: "kept" },
502
+ ]);
503
+ expect(dispatches).toBe(0);
504
+ expect(warnings).toEqual([]);
505
+ });
506
+
458
507
  it("appends a companion submit ActionSet with a fallback warning when $action is present", () => {
459
508
  const { card, warnings } = toAdaptiveCard({
460
509
  $type: "Select",
@@ -1154,6 +1203,29 @@ describe("toAdaptiveCard", () => {
1154
1203
  detail: `rows were clamped to ${TABLE_ROW_CAP} entries.`,
1155
1204
  });
1156
1205
  });
1206
+
1207
+ it("does not dispatch a row array through Symbol.species", () => {
1208
+ let dispatches = 0;
1209
+ const row = hostileSpecies(
1210
+ ["kept"],
1211
+ ["injected", "also-injected"],
1212
+ () => {
1213
+ dispatches += 1;
1214
+ },
1215
+ );
1216
+ const { card } = toAdaptiveCard({
1217
+ $type: "Table",
1218
+ columns: [{ label: "A" }],
1219
+ rows: [row],
1220
+ });
1221
+ const table = card.body[0] as TeamsTable;
1222
+ expect(table.rows).toHaveLength(2);
1223
+ expect(table.rows[1]?.cells).toHaveLength(1);
1224
+ expect(table.rows[1]?.cells[0]?.items).toEqual([
1225
+ { type: "TextBlock", text: "kept", wrap: true },
1226
+ ]);
1227
+ expect(dispatches).toBe(0);
1228
+ });
1157
1229
  });
1158
1230
 
1159
1231
  describe("Alert", () => {
@@ -1,19 +1,24 @@
1
+ import {
2
+ MAX_TRAVERSAL_DEPTH,
3
+ boundSpec,
4
+ clampReasonDetail,
5
+ } from "../convert/boundSpec";
6
+ import { copyBounded } from "../convert/copyBounded";
7
+ import { isElement } from "../convert/isElement";
8
+ import { takeRun } from "../convert/takeRun";
1
9
  import {
2
10
  normalizeSpec,
3
11
  type NormalizedUIElement,
4
12
  type NormalizedUINode,
5
13
  } from "../ir";
6
- import { boundSpec } from "./boundSpec";
7
14
  import {
8
15
  CHOICE_OPTION_CAP,
9
- MAX_TRAVERSAL_DEPTH,
10
16
  PAYLOAD_SOFT_CAP,
11
17
  PRIMARY_ACTION_CAP,
12
18
  TABLE_COLUMN_CAP,
13
19
  TABLE_ROW_CAP,
14
20
  buildCard,
15
21
  buildSubmitAction,
16
- clampReasonDetail,
17
22
  utf8ByteLength,
18
23
  } from "./constants";
19
24
  import type {
@@ -44,9 +49,6 @@ export interface ConversionContext {
44
49
  const isRecord = (value: unknown): value is Record<string, unknown> =>
45
50
  typeof value === "object" && value !== null && !Array.isArray(value);
46
51
 
47
- const isElement = (node: NormalizedUINode): node is NormalizedUIElement =>
48
- isRecord(node);
49
-
50
52
  const asString = (value: unknown): string =>
51
53
  typeof value === "string" ? value : "";
52
54
 
@@ -125,7 +127,7 @@ function reservedSafeId(
125
127
  }
126
128
 
127
129
  /**
128
- * The shared bounded-iteration primitive: slices `value` to `cap` entries
130
+ * The shared bounded-iteration primitive: copies `value` to `cap` entries
129
131
  * without ever reading past that many indices, so a hostile array (sparse or
130
132
  * proxied with a fabricated `length`) cannot stall the event loop.
131
133
  */
@@ -134,7 +136,7 @@ const clampArray = (
134
136
  cap: number,
135
137
  ): { readonly items: unknown[]; readonly truncated: boolean } => {
136
138
  if (!Array.isArray(value)) return { items: [], truncated: false };
137
- return { items: value.slice(0, cap), truncated: value.length > cap };
139
+ return copyBounded(value, cap);
138
140
  };
139
141
 
140
142
  const normalizedList = (
@@ -337,12 +339,13 @@ function convertTable(
337
339
  : undefined;
338
340
  const dataRows: TeamsTableRow[] = rawRows.map((row) => ({
339
341
  type: "TableRow",
340
- cells: (Array.isArray(row) ? row.slice(0, TABLE_COLUMN_CAP) : []).map(
341
- (cell) => ({
342
- type: "TableCell" as const,
343
- items: [textBlock(stringifyCell(cell))],
344
- }),
345
- ),
342
+ cells: (Array.isArray(row)
343
+ ? copyBounded(row, TABLE_COLUMN_CAP).items
344
+ : []
345
+ ).map((cell) => ({
346
+ type: "TableCell" as const,
347
+ items: [textBlock(stringifyCell(cell))],
348
+ })),
346
349
  }));
347
350
 
348
351
  return {
@@ -729,32 +732,22 @@ export function convertSequence(
729
732
  continue;
730
733
  }
731
734
  if (isElement(current) && current.type === "Fact") {
732
- const facts: NormalizedUIElement[] = [];
733
- while (index < nodes.length) {
734
- const candidate = nodes[index];
735
- if (!candidate || !isElement(candidate) || candidate.type !== "Fact") {
736
- break;
737
- }
738
- facts.push(candidate);
739
- index += 1;
740
- }
735
+ const { run: facts, next } = takeRun(
736
+ nodes,
737
+ index,
738
+ (candidate) => candidate.type === "Fact",
739
+ );
740
+ index = next;
741
741
  emit([convertFacts(facts)]);
742
742
  continue;
743
743
  }
744
744
  if (isElement(current) && current.type === "Button") {
745
- const buttons: NormalizedUIElement[] = [];
746
- while (index < nodes.length) {
747
- const candidate = nodes[index];
748
- if (
749
- !candidate ||
750
- !isElement(candidate) ||
751
- candidate.type !== "Button"
752
- ) {
753
- break;
754
- }
755
- buttons.push(candidate);
756
- index += 1;
757
- }
745
+ const { run: buttons, next } = takeRun(
746
+ nodes,
747
+ index,
748
+ (candidate) => candidate.type === "Button",
749
+ );
750
+ index = next;
758
751
  emit([convertButtons(buttons, context)]);
759
752
  continue;
760
753
  }
@@ -1,14 +1,14 @@
1
+ import { boundSpec, clampReasonDetail } from "../convert/boundSpec";
2
+ import { isElement } from "../convert/isElement";
1
3
  import {
2
4
  normalizeSpec,
3
5
  type NormalizedUIElement,
4
6
  type NormalizedUINode,
5
7
  } from "../ir";
6
- import { boundSpec } from "./boundSpec";
7
8
  import {
8
9
  CAROUSEL_ATTACHMENT_CAP,
9
10
  PAYLOAD_SOFT_CAP,
10
11
  buildAttachment,
11
- clampReasonDetail,
12
12
  utf8ByteLength,
13
13
  } from "./constants";
14
14
  import {
@@ -23,12 +23,6 @@ import type {
23
23
  ToAdaptiveCardOptions,
24
24
  } from "./types";
25
25
 
26
- const isRecord = (value: unknown): value is Record<string, unknown> =>
27
- typeof value === "object" && value !== null && !Array.isArray(value);
28
-
29
- const isElement = (node: NormalizedUINode): node is NormalizedUIElement =>
30
- isRecord(node);
31
-
32
26
  const normalizedList = (
33
27
  node: NormalizedUINode | undefined,
34
28
  ): NormalizedUINode[] => {