@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.
- package/dist/convert/boundSpec.d.ts +47 -0
- package/dist/convert/boundSpec.d.ts.map +1 -0
- package/dist/convert/boundSpec.js +99 -0
- package/dist/convert/boundSpec.js.map +1 -0
- package/dist/convert/copyBounded.d.ts +28 -0
- package/dist/convert/copyBounded.d.ts.map +1 -0
- package/dist/convert/copyBounded.js +38 -0
- package/dist/convert/copyBounded.js.map +1 -0
- package/dist/convert/isElement.d.ts +6 -0
- package/dist/convert/isElement.d.ts.map +1 -0
- package/dist/convert/isElement.js +6 -0
- package/dist/convert/isElement.js.map +1 -0
- package/dist/convert/takeRun.d.ts +9 -0
- package/dist/convert/takeRun.d.ts.map +1 -0
- package/dist/convert/takeRun.js +20 -0
- package/dist/convert/takeRun.js.map +1 -0
- package/dist/slack/constants.d.ts +1 -26
- package/dist/slack/constants.d.ts.map +1 -1
- package/dist/slack/constants.js +1 -26
- package/dist/slack/constants.js.map +1 -1
- package/dist/slack/fromSlackBlocks.js +6 -10
- package/dist/slack/fromSlackBlocks.js.map +1 -1
- package/dist/slack/toSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.js +28 -95
- package/dist/slack/toSlackBlocks.js.map +1 -1
- package/dist/teams/constants.d.ts +1 -28
- package/dist/teams/constants.d.ts.map +1 -1
- package/dist/teams/constants.js +1 -32
- package/dist/teams/constants.js.map +1 -1
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
- package/dist/teams/toAdaptiveCard.js +12 -23
- package/dist/teams/toAdaptiveCard.js.map +1 -1
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
- package/dist/teams/toTeamsAttachments.js +3 -4
- package/dist/teams/toTeamsAttachments.js.map +1 -1
- package/dist/vocabulary/list.js +4 -2
- package/dist/vocabulary/list.js.map +1 -1
- package/package.json +6 -6
- package/src/convert/boundSpec.test.ts +147 -0
- package/src/{teams → convert}/boundSpec.ts +48 -14
- package/src/convert/copyBounded.test.ts +131 -0
- package/src/convert/copyBounded.ts +38 -0
- package/src/convert/isElement.ts +6 -0
- package/src/convert/takeRun.test.ts +64 -0
- package/src/convert/takeRun.ts +19 -0
- package/src/slack/constants.ts +0 -29
- package/src/slack/fromSlackBlocks.test.ts +105 -7
- package/src/slack/fromSlackBlocks.ts +9 -9
- package/src/slack/toSlackBlocks.test.ts +82 -2
- package/src/slack/toSlackBlocks.ts +68 -164
- package/src/teams/constants.ts +0 -42
- package/src/teams/toAdaptiveCard.test.ts +74 -2
- package/src/teams/toAdaptiveCard.ts +29 -36
- package/src/teams/toTeamsAttachments.ts +2 -8
- package/src/vocabulary/list.test.tsx +35 -9
- package/src/vocabulary/list.tsx +4 -2
- package/dist/teams/boundSpec.d.ts +0 -20
- package/dist/teams/boundSpec.d.ts.map +0 -1
- package/dist/teams/boundSpec.js +0 -67
- 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,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
|
+
}
|
package/src/slack/constants.ts
CHANGED
|
@@ -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
|
|
1122
|
-
const hostile = new Proxy(
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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:
|
|
38
|
-
* without ever reading past that many indices, so a hostile array
|
|
39
|
-
* proxied with a fabricated `length`) cannot stall the event loop.
|
|
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
|
|
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 =
|
|
242
|
-
|
|
243
|
-
|
|
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]);
|