@assistant-ui/react-generative-ui 0.0.14 → 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 (61) hide show
  1. package/README.md +48 -0
  2. package/dist/convert/boundSpec.d.ts +47 -0
  3. package/dist/convert/boundSpec.d.ts.map +1 -0
  4. package/dist/convert/boundSpec.js +99 -0
  5. package/dist/convert/boundSpec.js.map +1 -0
  6. package/dist/convert/copyBounded.d.ts +28 -0
  7. package/dist/convert/copyBounded.d.ts.map +1 -0
  8. package/dist/convert/copyBounded.js +38 -0
  9. package/dist/convert/copyBounded.js.map +1 -0
  10. package/dist/convert/isElement.d.ts +6 -0
  11. package/dist/convert/isElement.d.ts.map +1 -0
  12. package/dist/convert/isElement.js +6 -0
  13. package/dist/convert/isElement.js.map +1 -0
  14. package/dist/convert/takeRun.d.ts +9 -0
  15. package/dist/convert/takeRun.d.ts.map +1 -0
  16. package/dist/convert/takeRun.js +20 -0
  17. package/dist/convert/takeRun.js.map +1 -0
  18. package/dist/slack/constants.d.ts +1 -26
  19. package/dist/slack/constants.d.ts.map +1 -1
  20. package/dist/slack/constants.js +1 -26
  21. package/dist/slack/constants.js.map +1 -1
  22. package/dist/slack/fromSlackBlocks.js +6 -10
  23. package/dist/slack/fromSlackBlocks.js.map +1 -1
  24. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  25. package/dist/slack/toSlackBlocks.js +28 -95
  26. package/dist/slack/toSlackBlocks.js.map +1 -1
  27. package/dist/teams/constants.d.ts +1 -28
  28. package/dist/teams/constants.d.ts.map +1 -1
  29. package/dist/teams/constants.js +1 -32
  30. package/dist/teams/constants.js.map +1 -1
  31. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  32. package/dist/teams/toAdaptiveCard.js +13 -24
  33. package/dist/teams/toAdaptiveCard.js.map +1 -1
  34. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  35. package/dist/teams/toTeamsAttachments.js +3 -4
  36. package/dist/teams/toTeamsAttachments.js.map +1 -1
  37. package/dist/vocabulary/list.js +4 -2
  38. package/dist/vocabulary/list.js.map +1 -1
  39. package/package.json +6 -6
  40. package/src/convert/boundSpec.test.ts +147 -0
  41. package/src/{teams → convert}/boundSpec.ts +48 -14
  42. package/src/convert/copyBounded.test.ts +131 -0
  43. package/src/convert/copyBounded.ts +38 -0
  44. package/src/convert/isElement.ts +6 -0
  45. package/src/convert/takeRun.test.ts +64 -0
  46. package/src/convert/takeRun.ts +19 -0
  47. package/src/slack/constants.ts +0 -29
  48. package/src/slack/fromSlackBlocks.test.ts +105 -7
  49. package/src/slack/fromSlackBlocks.ts +9 -9
  50. package/src/slack/toSlackBlocks.test.ts +82 -2
  51. package/src/slack/toSlackBlocks.ts +68 -164
  52. package/src/teams/constants.ts +0 -42
  53. package/src/teams/toAdaptiveCard.test.ts +75 -3
  54. package/src/teams/toAdaptiveCard.ts +30 -37
  55. package/src/teams/toTeamsAttachments.ts +2 -8
  56. package/src/vocabulary/list.test.tsx +35 -9
  57. package/src/vocabulary/list.tsx +4 -2
  58. package/dist/teams/boundSpec.d.ts +0 -20
  59. package/dist/teams/boundSpec.d.ts.map +0 -1
  60. package/dist/teams/boundSpec.js +0 -67
  61. package/dist/teams/boundSpec.js.map +0 -1
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { copyBounded } from "./copyBounded";
3
+
4
+ const hostileSpecies = (real: unknown[], injected: unknown[]) => {
5
+ function HostileCtor() {
6
+ return { map: () => injected, some: () => true, length: 0 };
7
+ }
8
+ const constructor = { [Symbol.species]: HostileCtor };
9
+ return new Proxy(real, {
10
+ get: (target, prop, receiver) =>
11
+ prop === "constructor"
12
+ ? constructor
13
+ : Reflect.get(target, prop, receiver),
14
+ });
15
+ };
16
+
17
+ describe("copyBounded", () => {
18
+ it("copies every entry of a shorter array", () => {
19
+ expect(copyBounded(["a", "b"], 5).items).toEqual(["a", "b"]);
20
+ });
21
+
22
+ it("stops at the cap", () => {
23
+ expect(copyBounded(["a", "b", "c"], 2).items).toEqual(["a", "b"]);
24
+ });
25
+
26
+ it("returns a fresh array rather than the input", () => {
27
+ const input = ["a"];
28
+ expect(copyBounded(input, 5).items).not.toBe(input);
29
+ });
30
+
31
+ it("leaves an absent index absent, as slice does", () => {
32
+ const sparse: unknown[] = [];
33
+ sparse[0] = "a";
34
+ sparse[2] = "c";
35
+
36
+ const { items: result } = copyBounded(sparse, 5);
37
+
38
+ expect(result).toHaveLength(3);
39
+ expect(1 in result).toBe(false);
40
+ expect(result).toEqual(sparse.slice(0, 5));
41
+ });
42
+
43
+ it("does not read an index the input reports as absent", () => {
44
+ const reads: string[] = [];
45
+ const probe = new Proxy(["a", "b"], {
46
+ has: (target, prop) => prop !== "1" && Reflect.has(target, prop),
47
+ get: (target, prop, receiver) => {
48
+ if (typeof prop === "string" && /^\d+$/.test(prop)) reads.push(prop);
49
+ return Reflect.get(target, prop, receiver);
50
+ },
51
+ });
52
+
53
+ const { items: result } = copyBounded(probe, 2);
54
+
55
+ expect(reads).toEqual(["0"]);
56
+ expect(1 in result).toBe(false);
57
+ });
58
+
59
+ it("reports truncation from the same length read that bounds the copy", () => {
60
+ let reads = 0;
61
+ const shifty = new Proxy(["a", "b", "c"], {
62
+ get: (target, prop, receiver) => {
63
+ if (prop === "length") {
64
+ reads += 1;
65
+ return reads === 1 ? 3 : 1;
66
+ }
67
+ return Reflect.get(target, prop, receiver);
68
+ },
69
+ });
70
+
71
+ const { items, truncated } = copyBounded(shifty, 2);
72
+
73
+ expect(items).toHaveLength(2);
74
+ expect(truncated).toBe(true);
75
+ });
76
+
77
+ it("matches slice for every shape of reported length", () => {
78
+ const reportedLengths = [2.5, Infinity, NaN, -1, "3", 2, 0, -0.5, 1e21];
79
+ const lying = (length: unknown) =>
80
+ new Proxy(["a", "b", "c"], {
81
+ get: (target, prop, receiver) =>
82
+ prop === "length" ? length : Reflect.get(target, prop, receiver),
83
+ });
84
+
85
+ for (const cap of [0, 1, 2, 5]) {
86
+ for (const length of reportedLengths) {
87
+ const { items, truncated } = copyBounded(lying(length), cap);
88
+
89
+ expect(items).toEqual(
90
+ Array.prototype.slice.call(lying(length), 0, cap),
91
+ );
92
+ expect(truncated).toBe(
93
+ Array.prototype.slice.call(lying(length), 0, cap + 1).length > cap,
94
+ );
95
+ }
96
+ }
97
+ });
98
+
99
+ it("bounds an array whose reported length is fabricated", () => {
100
+ const hostile = new Proxy(["a", "b"], {
101
+ get: (target, prop, receiver) =>
102
+ prop === "length"
103
+ ? Number.MAX_SAFE_INTEGER
104
+ : Reflect.get(target, prop, receiver),
105
+ });
106
+
107
+ expect(copyBounded(hostile, 3).items).toEqual(["a", "b", undefined]);
108
+ });
109
+
110
+ it("bounds an array that replaces its slice", () => {
111
+ const hostile = new Proxy(["a", "b"], {
112
+ get: (target, prop, receiver) =>
113
+ prop === "slice"
114
+ ? () => Array(500).fill("injected")
115
+ : Reflect.get(target, prop, receiver),
116
+ });
117
+
118
+ expect(copyBounded(hostile, 5).items).toEqual(["a", "b"]);
119
+ });
120
+
121
+ it("bounds an array that redirects Symbol.species to a foreign object", () => {
122
+ const injected = Array(500).fill("injected");
123
+ const { items: result } = copyBounded(
124
+ hostileSpecies(["a", "b"], injected),
125
+ 5,
126
+ );
127
+
128
+ expect(Array.isArray(result)).toBe(true);
129
+ expect(result).toEqual(["a", "b"]);
130
+ });
131
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Copies at most `cap` entries out of `value` by index.
3
+ *
4
+ * Every array method dispatches on the input: a replaced `slice` is called
5
+ * directly, and the intrinsic `Array.prototype.slice.call` still reaches a
6
+ * `constructor` with a `@@species` whose `[[Construct]]` returns an arbitrary
7
+ * object, which then supplies the `map` or `some` the caller runs next. Reading
8
+ * indices consults neither, so it is the only form that bounds a hostile array.
9
+ *
10
+ * Absent indices stay absent, matching what `slice` produces for a sparse
11
+ * input, so callers that skip holes still skip them. `truncated` comes from the
12
+ * same `length` read as the bound, so an input cannot report one figure to the
13
+ * cap and another to the caller's warning.
14
+ *
15
+ * A reported `length` is normalized the way `ToLength` does before it reaches
16
+ * `count`, because a fractional one would otherwise reach `items.length` and
17
+ * throw `RangeError` where `slice` returns a bounded result. `truncated` reads
18
+ * that same normalized figure, so a `length` whose `valueOf` answers differently
19
+ * on a second conversion cannot separate the warning from the bound.
20
+ */
21
+ export const copyBounded = <T>(
22
+ value: readonly T[],
23
+ cap: number,
24
+ ): { readonly items: T[]; readonly truncated: boolean } => {
25
+ const reported = value.length;
26
+ const integral = Math.trunc(Number(reported));
27
+ const usable =
28
+ Number.isNaN(integral) || integral <= 0
29
+ ? 0
30
+ : Math.min(integral, Number.MAX_SAFE_INTEGER);
31
+ const count = Math.min(cap, usable);
32
+ const items: T[] = [];
33
+ for (let index = 0; index < count; index += 1) {
34
+ if (index in value) items[index] = value[index] as T;
35
+ }
36
+ items.length = count;
37
+ return { items, truncated: usable > cap };
38
+ };
@@ -0,0 +1,6 @@
1
+ import type { NormalizedUIElement, NormalizedUINode } from "../ir";
2
+
3
+ export const isElement = (
4
+ node: NormalizedUINode,
5
+ ): node is NormalizedUIElement =>
6
+ typeof node === "object" && node !== null && !Array.isArray(node);
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { NormalizedUIElement, NormalizedUINode } from "../ir";
3
+ import { takeRun } from "./takeRun";
4
+
5
+ const element = (type: string): NormalizedUIElement => ({ type, props: {} });
6
+
7
+ const isFact = (candidate: NormalizedUIElement) => candidate.type === "Fact";
8
+
9
+ describe("takeRun", () => {
10
+ it("collects the contiguous matching run and reports the resume index", () => {
11
+ const fact = element("Fact");
12
+ const nodes = [fact, fact, element("Text"), fact];
13
+
14
+ expect(takeRun(nodes, 0, isFact)).toEqual({ run: [fact, fact], next: 2 });
15
+ });
16
+
17
+ it("starts at the given index rather than the head of the list", () => {
18
+ const fact = element("Fact");
19
+ const nodes = [element("Text"), fact, fact];
20
+
21
+ expect(takeRun(nodes, 1, isFact)).toEqual({ run: [fact, fact], next: 3 });
22
+ });
23
+
24
+ it("stops at the end of the list", () => {
25
+ const fact = element("Fact");
26
+
27
+ expect(takeRun([fact, fact], 0, isFact)).toEqual({
28
+ run: [fact, fact],
29
+ next: 2,
30
+ });
31
+ });
32
+
33
+ it("returns an empty run without advancing when the first node does not match", () => {
34
+ expect(takeRun([element("Text"), element("Fact")], 0, isFact)).toEqual({
35
+ run: [],
36
+ next: 0,
37
+ });
38
+ });
39
+
40
+ it("stops on every non-element node shape", () => {
41
+ const fact = element("Fact");
42
+ const nonElements: (NormalizedUINode | undefined)[] = [
43
+ undefined,
44
+ null,
45
+ "",
46
+ "text",
47
+ 0,
48
+ 1,
49
+ [],
50
+ [fact],
51
+ ];
52
+
53
+ for (const nonElement of nonElements) {
54
+ expect(takeRun([fact, nonElement, fact], 0, isFact)).toEqual({
55
+ run: [fact],
56
+ next: 1,
57
+ });
58
+ }
59
+ });
60
+
61
+ it("stops past the end of the list", () => {
62
+ expect(takeRun([element("Fact")], 1, isFact)).toEqual({ run: [], next: 1 });
63
+ });
64
+ });
@@ -0,0 +1,19 @@
1
+ import type { NormalizedUIElement, NormalizedUINode } from "../ir";
2
+ import { isElement } from "./isElement";
3
+
4
+ export function takeRun(
5
+ nodes: readonly (NormalizedUINode | undefined)[],
6
+ index: number,
7
+ matches: (element: NormalizedUIElement) => boolean,
8
+ ): { run: NormalizedUIElement[]; next: number } {
9
+ const run: NormalizedUIElement[] = [];
10
+ let next = index;
11
+ while (next < nodes.length) {
12
+ const candidate = nodes[next];
13
+ if (candidate === undefined || !isElement(candidate) || !matches(candidate))
14
+ break;
15
+ run.push(candidate);
16
+ next += 1;
17
+ }
18
+ return { run, next };
19
+ }
@@ -11,35 +11,6 @@ import type {
11
11
  SlackTextObject,
12
12
  } from "./types";
13
13
 
14
- /** The maximum traversal depth, counted in visited values, not tree levels. */
15
- export const MAX_TRAVERSAL_DEPTH = 64;
16
-
17
- /**
18
- * The element-nesting ceiling a caller observes. `boundNode` visits an element
19
- * and its `children` array separately, so one level of nesting spends two of
20
- * {@link MAX_TRAVERSAL_DEPTH}; it runs before the conversion walk and is never
21
- * the looser of the two. A root array costs one more level than a root object.
22
- */
23
- export const MAX_ELEMENT_DEPTH = MAX_TRAVERSAL_DEPTH / 2;
24
-
25
- /**
26
- * The maximum number of entries kept at any single level (root, or a
27
- * `children` array at any depth) of the raw spec, before it ever reaches
28
- * `normalizeSpec`. Bounds a hostile array's reported `length` so the pre-pass
29
- * can never be made to walk further than this regardless of what the array
30
- * claims about itself.
31
- */
32
- export const CHILDREN_CAP = 200;
33
-
34
- /**
35
- * The total number of nodes the pre-normalization bounding walk will visit
36
- * across one call, regardless of how many times a shared reference recurs.
37
- * Bounds the combinatorial work a DAG of shared or self-referential nodes
38
- * would otherwise force even though each individual array stays within
39
- * {@link CHILDREN_CAP}.
40
- */
41
- export const NODE_BUDGET = 5000;
42
-
43
14
  /** The block limit for a Slack message. */
44
15
  export const MESSAGE_BLOCK_CAP = 50;
45
16
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { UIElement } from "../ir";
3
- import { INBOUND_BLOCK_CAP } from "./constants";
3
+ import { INBOUND_BLOCK_CAP, SELECT_OPTION_CAP } from "./constants";
4
4
  import { fromSlackBlocks } from "./fromSlackBlocks";
5
5
  import { toSlackBlocks } from "./toSlackBlocks";
6
6
  import type { ToSlackBlocksOptions } from "./types";
@@ -1118,14 +1118,112 @@ describe("fromSlackBlocks checkbox and radio caps", () => {
1118
1118
  });
1119
1119
  });
1120
1120
 
1121
- it("bounds hostile initial_options arrays when deriving defaultChecked", () => {
1122
- const hostile = new Proxy([], {
1123
- get(target, prop) {
1124
- if (prop === "length") return 2 ** 31;
1125
- if (prop === "slice") return Array.prototype.slice.bind(target);
1126
- return Reflect.get(target, prop);
1121
+ it("bounds static-select options when an array replaces its slice", () => {
1122
+ const hostile = new Proxy(
1123
+ Array.from({ length: SELECT_OPTION_CAP + 1 }, (_, index) => ({
1124
+ text: { type: "plain_text", text: `Option ${index}` },
1125
+ value: `value-${index}`,
1126
+ })),
1127
+ {
1128
+ get(target, prop, receiver) {
1129
+ if (prop === "slice") {
1130
+ return () =>
1131
+ Array.from({ length: SELECT_OPTION_CAP * 2 }, () => ({
1132
+ text: { type: "plain_text", text: "Injected" },
1133
+ value: "injected",
1134
+ }));
1135
+ }
1136
+ return Reflect.get(target, prop, receiver);
1137
+ },
1138
+ },
1139
+ );
1140
+
1141
+ const { nodes } = fromSlackBlocks([
1142
+ {
1143
+ type: "actions",
1144
+ elements: [
1145
+ { type: "static_select", action_id: "pick", options: hostile },
1146
+ ],
1127
1147
  },
1148
+ ]);
1149
+ const select = nodes[0] as unknown as { options: unknown[] };
1150
+
1151
+ expect(select.options).toHaveLength(SELECT_OPTION_CAP);
1152
+ expect(select.options[0]).toEqual({ label: "Option 0", value: "value-0" });
1153
+ expect(select.options.at(-1)).toEqual({
1154
+ label: `Option ${SELECT_OPTION_CAP - 1}`,
1155
+ value: `value-${SELECT_OPTION_CAP - 1}`,
1128
1156
  });
1157
+ });
1158
+
1159
+ it("skips a hole in a section fields array instead of decoding a blank fact", () => {
1160
+ const fields: unknown[] = [];
1161
+ fields[0] = { type: "mrkdwn", text: "*A*\nyes" };
1162
+ fields[2] = { type: "mrkdwn", text: "*C*\nyes" };
1163
+
1164
+ const { nodes, warnings } = fromSlackBlocks([{ type: "section", fields }]);
1165
+
1166
+ expect(warnings).toEqual([]);
1167
+ expect(nodes.filter(Boolean)).toEqual([
1168
+ { $type: "Fact", label: "A", value: "yes" },
1169
+ { $type: "Fact", label: "C", value: "yes" },
1170
+ ]);
1171
+ });
1172
+
1173
+ it("bounds static-select options when an array redirects Symbol.species", () => {
1174
+ function HostileCtor() {
1175
+ return {
1176
+ map: () =>
1177
+ Array.from({ length: SELECT_OPTION_CAP * 5 }, () => ({
1178
+ label: "Injected",
1179
+ value: "injected",
1180
+ })),
1181
+ length: 0,
1182
+ };
1183
+ }
1184
+ const constructor = { [Symbol.species]: HostileCtor };
1185
+ const hostile = new Proxy(
1186
+ Array.from({ length: SELECT_OPTION_CAP + 1 }, (_, index) => ({
1187
+ text: { type: "plain_text", text: `Option ${index}` },
1188
+ value: `value-${index}`,
1189
+ })),
1190
+ {
1191
+ get: (target, prop, receiver) =>
1192
+ prop === "constructor"
1193
+ ? constructor
1194
+ : Reflect.get(target, prop, receiver),
1195
+ },
1196
+ );
1197
+
1198
+ const { nodes } = fromSlackBlocks([
1199
+ {
1200
+ type: "actions",
1201
+ elements: [
1202
+ { type: "static_select", action_id: "pick", options: hostile },
1203
+ ],
1204
+ },
1205
+ ]);
1206
+ const select = nodes[0] as unknown as { options: unknown[] };
1207
+
1208
+ expect(select.options).toHaveLength(SELECT_OPTION_CAP);
1209
+ expect(select.options[0]).toEqual({ label: "Option 0", value: "value-0" });
1210
+ });
1211
+
1212
+ it("bounds hostile initial_options arrays when deriving defaultChecked", () => {
1213
+ const hostile = new Proxy(
1214
+ [{ text: { type: "plain_text", text: "Other" }, value: "other" }],
1215
+ {
1216
+ get(target, prop, receiver) {
1217
+ if (prop === "length") return 2 ** 31;
1218
+ if (prop === "slice") {
1219
+ return () => [
1220
+ { text: { type: "plain_text", text: "First" }, value: "first" },
1221
+ ];
1222
+ }
1223
+ return Reflect.get(target, prop, receiver);
1224
+ },
1225
+ },
1226
+ );
1129
1227
  const { nodes } = fromSlackBlocks([
1130
1228
  {
1131
1229
  type: "actions",
@@ -1,3 +1,4 @@
1
+ import { copyBounded } from "../convert/copyBounded";
1
2
  import type { Action, UIElement } from "../ir";
2
3
  import {
3
4
  ACTIONS_ELEMENT_CAP,
@@ -34,18 +35,16 @@ const warn = (
34
35
  };
35
36
 
36
37
  /**
37
- * The shared bounded-iteration primitive: slices `value` to `cap` entries
38
- * without ever reading past that many indices, so a hostile array (sparse or
39
- * proxied with a fabricated `length`) cannot stall the event loop. `slice`
40
- * itself performs the bounded read; only its own reported length is
41
- * inspected to detect truncation.
38
+ * The shared bounded-iteration primitive: copies `value` to at most `cap`
39
+ * entries without ever reading past that many indices, so a hostile array
40
+ * (sparse or proxied with a fabricated `length`) cannot stall the event loop.
42
41
  */
43
42
  const clampArray = (
44
43
  value: unknown,
45
44
  cap: number,
46
45
  ): { readonly items: unknown[]; readonly truncated: boolean } => {
47
46
  if (!Array.isArray(value)) return { items: [], truncated: false };
48
- return { items: value.slice(0, cap), truncated: value.length > cap };
47
+ return copyBounded(value, cap);
49
48
  };
50
49
 
51
50
  const boundedArray = (
@@ -238,9 +237,10 @@ const checkboxFrom = (element: Record<string, unknown>): UIElement => {
238
237
  : [];
239
238
  const firstValue =
240
239
  isRecord(first) && typeof first["value"] === "string" ? first["value"] : "";
241
- const firstChecked = initialOptions
242
- .slice(0, SELECT_OPTION_CAP)
243
- .some((option) => isRecord(option) && option["value"] === firstValue);
240
+ const firstChecked = copyBounded(
241
+ initialOptions,
242
+ SELECT_OPTION_CAP,
243
+ ).items.some((option) => isRecord(option) && option["value"] === firstValue);
244
244
  return {
245
245
  $type: "Checkbox",
246
246
  label: isRecord(first) ? textOf(first["text"]) : "",
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { CHILDREN_CAP, NODE_BUDGET } from "../convert/boundSpec";
2
3
  import { toSlackBlocks } from "./toSlackBlocks";
3
4
  import {
4
5
  ACTION_ID_CAP,
@@ -9,7 +10,6 @@ import {
9
10
  CARD_SUBTEXT_CAP,
10
11
  CARD_TITLE_CAP,
11
12
  CAROUSEL_CARD_CAP,
12
- CHILDREN_CAP,
13
13
  CONTEXT_ELEMENT_CAP,
14
14
  CONTEXT_TEXT_CAP,
15
15
  DATA_TABLE_CHAR_BUDGET,
@@ -23,7 +23,6 @@ import {
23
23
  MARKDOWN_TEXT_BUDGET,
24
24
  MESSAGE_BLOCK_CAP,
25
25
  MODAL_BLOCK_CAP,
26
- NODE_BUDGET,
27
26
  PLACEHOLDER_TEXT_CAP,
28
27
  RADIO_OPTION_CAP,
29
28
  SECTION_TEXT_CAP,
@@ -44,6 +43,32 @@ import type {
44
43
  SlackStaticSelectElement,
45
44
  } from "./types";
46
45
 
46
+ const hostileSpecies = <T>(
47
+ source: T[],
48
+ injected: unknown[],
49
+ onDispatch: () => void,
50
+ ): T[] => {
51
+ function HostileCtor() {
52
+ return {
53
+ map: () => {
54
+ onDispatch();
55
+ return injected;
56
+ },
57
+ [Symbol.iterator]: function* () {
58
+ onDispatch();
59
+ yield* injected;
60
+ },
61
+ };
62
+ }
63
+ const constructor = { [Symbol.species]: HostileCtor };
64
+ return new Proxy(source, {
65
+ get: (target, prop, receiver) =>
66
+ prop === "constructor"
67
+ ? constructor
68
+ : Reflect.get(target, prop, receiver),
69
+ });
70
+ };
71
+
47
72
  describe("toSlackBlocks", () => {
48
73
  describe("Header", () => {
49
74
  it("renders a header block", () => {
@@ -389,6 +414,32 @@ describe("toSlackBlocks", () => {
389
414
  });
390
415
  });
391
416
 
417
+ it("does not dispatch a prop array through Symbol.species", () => {
418
+ let dispatches = 0;
419
+ const injected = Array.from(
420
+ { length: SELECT_OPTION_CAP + 1 },
421
+ (_, i) => ({ label: `injected-${i}`, value: `injected-${i}` }),
422
+ );
423
+ const { blocks, warnings } = toSlackBlocks({
424
+ $type: "Select",
425
+ options: hostileSpecies(
426
+ [{ label: "kept", value: "kept" }],
427
+ injected,
428
+ () => {
429
+ dispatches += 1;
430
+ },
431
+ ),
432
+ $action: { type: "pick" },
433
+ });
434
+ const element = (blocks[0] as SlackActionsBlock)
435
+ .elements[0] as SlackStaticSelectElement;
436
+ expect(element.options).toEqual([
437
+ { text: { type: "plain_text", text: "kept" }, value: "kept" },
438
+ ]);
439
+ expect(dispatches).toBe(0);
440
+ expect(warnings).toEqual([]);
441
+ });
442
+
392
443
  it(`clamps an option label to ${INTERACTIVE_TEXT_CAP} characters and warns`, () => {
393
444
  const longLabel = "y".repeat(90);
394
445
  const { blocks, warnings } = toSlackBlocks({
@@ -1685,6 +1736,35 @@ describe("toSlackBlocks", () => {
1685
1736
  );
1686
1737
  });
1687
1738
 
1739
+ it("does not dispatch columns, rows, or row arrays through Symbol.species", () => {
1740
+ const dispatches = { columns: 0, rows: 0, row: 0 };
1741
+ const row = hostileSpecies(
1742
+ ["kept"],
1743
+ ["injected", "also-injected"],
1744
+ () => {
1745
+ dispatches.row += 1;
1746
+ },
1747
+ );
1748
+ const { blocks } = toSlackBlocks({
1749
+ $type: "Table",
1750
+ columns: hostileSpecies(
1751
+ [{ label: "Kept" }],
1752
+ [{ label: "Injected" }, { label: "Also injected" }],
1753
+ () => {
1754
+ dispatches.columns += 1;
1755
+ },
1756
+ ),
1757
+ rows: hostileSpecies([row], [row, row], () => {
1758
+ dispatches.rows += 1;
1759
+ }),
1760
+ });
1761
+ const table = blocks[0] as SlackDataTableBlock;
1762
+ expect(table.rows).toHaveLength(2);
1763
+ expect(table.rows[0]).toEqual([{ type: "raw_text", text: "Kept" }]);
1764
+ expect(table.rows[1]).toEqual([{ type: "raw_text", text: "kept" }]);
1765
+ expect(dispatches).toEqual({ columns: 0, rows: 0, row: 0 });
1766
+ });
1767
+
1688
1768
  it(`clamps rows to fit the ${DATA_TABLE_CHAR_BUDGET}-character table budget, always keeping the header row`, () => {
1689
1769
  const bigCell = "z".repeat(Math.ceil(DATA_TABLE_CHAR_BUDGET / 5));
1690
1770
  const rows = Array.from({ length: 6 }, (_, r) => [`row${r}`, bigCell]);