@frockbot/kernel-contracts 0.3.0 → 0.3.2

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.
@@ -31,6 +31,53 @@ describe("the kernel-declared bundler seam", () => {
31
31
  expect(decodePackageBundleArtifactV1(ARTIFACT)).toEqual(ARTIFACT as never);
32
32
  });
33
33
 
34
+ test("decodes multi-page UI artifacts exactly", () => {
35
+ const page = (id: string, hash: string) => ({
36
+ id,
37
+ artifact: {
38
+ contentHash: hash.repeat(64),
39
+ size: 32,
40
+ mediaType: "text/html",
41
+ bundlerVersion: "frockbot-inline-html@1",
42
+ },
43
+ html: `<!doctype html><h1>${id}</h1>`,
44
+ });
45
+ const bundled = (uiArtifacts: unknown) => ({
46
+ schemaVersion: 1,
47
+ effectId: "author-1",
48
+ status: "bundled",
49
+ artifact: ARTIFACT,
50
+ module: "export const tools = [];",
51
+ uiArtifacts,
52
+ diagnostics: [],
53
+ });
54
+ const result = decodePackageBundleResultV1(
55
+ bundled([page("main", "b"), page("board", "c")]),
56
+ );
57
+ expect(
58
+ result.status === "bundled"
59
+ ? result.uiArtifacts?.map((entry) => entry.id)
60
+ : undefined,
61
+ ).toEqual(["main", "board"]);
62
+ expect(() => decodePackageBundleResultV1(bundled([]))).toThrow(
63
+ "non-empty bounded array",
64
+ );
65
+ expect(() =>
66
+ decodePackageBundleResultV1(
67
+ bundled([page("main", "b"), page("main", "c")]),
68
+ ),
69
+ ).toThrow("duplicate ids");
70
+ expect(() =>
71
+ decodePackageBundleResultV1(bundled([page("Main", "b")])),
72
+ ).toThrow("id is invalid");
73
+ expect(() =>
74
+ decodePackageBundleResultV1({
75
+ ...bundled([page("main", "b")]),
76
+ uiHtml: "<!doctype html>",
77
+ }),
78
+ ).toThrow("invalid fields");
79
+ });
80
+
34
81
  test("decodes a failed result exactly", () => {
35
82
  expect(
36
83
  decodePackageBundleResultV1({
package/src/authoring.ts CHANGED
@@ -18,11 +18,29 @@ export const PACKAGE_BUNDLE_ENTRY = "package.ts";
18
18
  /** Raw HTML is content-addressed without transforming it. */
19
19
  export const PACKAGE_UI_ARTIFACT_VERSION = "frockbot-inline-html@1";
20
20
 
21
+ /** Manifest v5: 1..8 UI pages, each hashed and stored under its own id. */
22
+ export const PACKAGE_UI_MAX_PAGES = 8;
23
+ export const PACKAGE_UI_PAGE_ID = /^[a-z][a-z0-9-]{0,31}$/;
24
+
21
25
  export interface PackageBundleSourceV1 {
22
26
  path: string;
23
27
  text: string;
24
28
  }
25
29
 
30
+ /** One inline-only HTML page in a bundle request. */
31
+ export interface PackageUiPageSourceV1 {
32
+ id: string;
33
+ html: string;
34
+ }
35
+
36
+ /** One content-addressed HTML page in a bundle result. */
37
+ export interface PackageUiArtifactPageV1 {
38
+ id: string;
39
+ artifact: PackageUiArtifactV1;
40
+ /** Exact HTML bytes; the Durable Object owns the immutable write. */
41
+ html: string;
42
+ }
43
+
26
44
  export interface PackageBundleRequestV1 {
27
45
  schemaVersion: 1;
28
46
  /** Idempotency key: the Durable-Object-recorded authorship intent id. */
@@ -31,8 +49,8 @@ export interface PackageBundleRequestV1 {
31
49
  compatibilityDate: string;
32
50
  entry: "package.ts";
33
51
  sources: PackageBundleSourceV1[];
34
- /** Optional immutable inline-only HTML page; never compiled into app code. */
35
- ui?: { path: "ui.html"; html: string };
52
+ /** Immutable inline-only HTML pages; never compiled into app code. */
53
+ uiPages?: PackageUiPageSourceV1[];
36
54
  }
37
55
 
38
56
  export interface PackageBundleArtifactV1 {
@@ -57,9 +75,7 @@ export type PackageBundleResultV1 =
57
75
  effectId: string;
58
76
  status: "bundled";
59
77
  artifact: PackageBundleArtifactV1;
60
- uiArtifact?: PackageUiArtifactV1;
61
- /** Exact HTML bytes; the Durable Object owns the immutable write. */
62
- uiHtml?: string;
78
+ uiArtifacts?: PackageUiArtifactPageV1[];
63
79
  /** The module bytes as text; the Durable Object owns the artifact write. */
64
80
  module: string;
65
81
  diagnostics: string[];
@@ -191,12 +207,7 @@ export function decodePackageBundleResultV1(
191
207
  throw new Error(`${label}.schemaVersion is unsupported`);
192
208
  const effectId = boundedString(value.effectId, `${label}.effectId`, 200);
193
209
  if (value.status === "bundled") {
194
- const hasUi = value.uiArtifact !== undefined || value.uiHtml !== undefined;
195
- if ((value.uiArtifact === undefined) !== (value.uiHtml === undefined)) {
196
- throw new Error(
197
- `${label} must return UI artifact metadata and HTML together`,
198
- );
199
- }
210
+ const hasUi = value.uiArtifacts !== undefined;
200
211
  exactKeys(
201
212
  value,
202
213
  [
@@ -206,7 +217,7 @@ export function decodePackageBundleResultV1(
206
217
  "artifact",
207
218
  "module",
208
219
  "diagnostics",
209
- ...(hasUi ? ["uiArtifact", "uiHtml"] : []),
220
+ ...(hasUi ? ["uiArtifacts"] : []),
210
221
  ],
211
222
  label,
212
223
  );
@@ -216,14 +227,39 @@ export function decodePackageBundleResultV1(
216
227
  );
217
228
  if (typeof value.module !== "string" || value.module.length === 0)
218
229
  throw new Error(`${label}.module must be a non-empty string`);
219
- const uiArtifact = hasUi
220
- ? decodePackageUiArtifactV1(value.uiArtifact, `${label}.uiArtifact`)
221
- : undefined;
222
- if (
223
- hasUi &&
224
- (typeof value.uiHtml !== "string" || value.uiHtml.length === 0)
225
- ) {
226
- throw new Error(`${label}.uiHtml must be a non-empty string`);
230
+ let uiArtifacts: PackageUiArtifactPageV1[] | undefined;
231
+ if (hasUi) {
232
+ if (
233
+ !Array.isArray(value.uiArtifacts) ||
234
+ value.uiArtifacts.length === 0 ||
235
+ value.uiArtifacts.length > PACKAGE_UI_MAX_PAGES
236
+ ) {
237
+ throw new Error(
238
+ `${label}.uiArtifacts must be a non-empty bounded array`,
239
+ );
240
+ }
241
+ uiArtifacts = value.uiArtifacts.map((candidate, index) => {
242
+ const pageLabel = `${label}.uiArtifacts[${index}]`;
243
+ const page = record(candidate, pageLabel);
244
+ exactKeys(page, ["id", "artifact", "html"], pageLabel);
245
+ const id = boundedString(page.id, `${pageLabel}.id`, 32);
246
+ if (!PACKAGE_UI_PAGE_ID.test(id))
247
+ throw new Error(`${pageLabel}.id is invalid`);
248
+ if (typeof page.html !== "string" || page.html.length === 0)
249
+ throw new Error(`${pageLabel}.html must be a non-empty string`);
250
+ return {
251
+ id,
252
+ artifact: decodePackageUiArtifactV1(
253
+ page.artifact,
254
+ `${pageLabel}.artifact`,
255
+ ),
256
+ html: page.html,
257
+ };
258
+ });
259
+ if (
260
+ new Set(uiArtifacts.map((page) => page.id)).size !== uiArtifacts.length
261
+ )
262
+ throw new Error(`${label}.uiArtifacts contains duplicate ids`);
227
263
  }
228
264
  return {
229
265
  schemaVersion: 1,
@@ -231,7 +267,7 @@ export function decodePackageBundleResultV1(
231
267
  status: "bundled",
232
268
  artifact,
233
269
  module: value.module,
234
- ...(uiArtifact ? { uiArtifact, uiHtml: value.uiHtml as string } : {}),
270
+ ...(uiArtifacts ? { uiArtifacts } : {}),
235
271
  diagnostics: diagnostics(value.diagnostics, `${label}.diagnostics`),
236
272
  };
237
273
  }
@@ -0,0 +1,102 @@
1
+ import type { Plugin } from "cordis";
2
+
3
+ /**
4
+ * How a first-party Contribution names the code that implements it.
5
+ *
6
+ * The constitution's rule is that every Contribution kind is resolved from the
7
+ * manifest and an artifact, never from a switch over Package identity. A
8
+ * non-first-party Package satisfies that by carrying an immutable artifact the
9
+ * loader mounts. A first-party Package that still runs in the kernel's own
10
+ * isolate satisfies it by exporting a **descriptor** beside its factory: the
11
+ * Contribution specifier its manifest declares, the host it belongs in, and
12
+ * the one function that builds it. An application then owns a table keyed by
13
+ * specifier and nothing else — no `if`, no `switch`, no comparison against a
14
+ * Package's name — and a specifier in a plan that reaches neither the table
15
+ * nor an artifact is an error when the application is compiled.
16
+ *
17
+ * These types deliberately know nothing about any Package: a descriptor is a
18
+ * specifier, a host, and a closure whose host object the *application*
19
+ * supplies. That is what lets the kernel declare them while importing no
20
+ * Package.
21
+ */
22
+
23
+ /** The execution host a backend Contribution's factory is mounted in. */
24
+ export type BackendContributionHostV1 = "gateway" | "bot" | "user";
25
+
26
+ /** What a Contribution's factory is handed to publish the value it mounts. */
27
+ export interface ContributionLifecycleV1<Contribution> {
28
+ mount(contribution: Contribution): () => void;
29
+ }
30
+
31
+ /**
32
+ * A first-party backend Contribution: the specifier its manifest declares, the
33
+ * host it belongs in, and the factory that mounts it into a Cordis fiber.
34
+ *
35
+ * `Host` is contravariant, so a descriptor written against the narrow host
36
+ * interface its own Package declares is usable by an application whose wide
37
+ * host object satisfies every Package's slice of it.
38
+ */
39
+ export interface BackendContributionDescriptorV1<Host, Contribution> {
40
+ readonly kind: "backend";
41
+ readonly specifier: string;
42
+ readonly host: BackendContributionHostV1;
43
+ create(host: Host, lifecycle: ContributionLifecycleV1<Contribution>): Plugin;
44
+ }
45
+
46
+ /**
47
+ * A first-party client Contribution. A client Plugin takes no host — the
48
+ * client runtime is its host — so the descriptor carries the Plugin itself.
49
+ * The Plugin type stays generic so this module imports no client package.
50
+ */
51
+ export interface ClientContributionDescriptorV1<ClientPlugin> {
52
+ readonly kind: "client";
53
+ readonly specifier: string;
54
+ readonly plugin: ClientPlugin;
55
+ }
56
+
57
+ /** Any first-party Contribution descriptor an application table can hold. */
58
+ export type ContributionDescriptorV1<ClientPlugin = unknown> =
59
+ | BackendContributionDescriptorV1<never, unknown>
60
+ | ClientContributionDescriptorV1<ClientPlugin>;
61
+
62
+ export function defineBackendContribution<Host, Contribution>(
63
+ descriptor: Omit<BackendContributionDescriptorV1<Host, Contribution>, "kind">,
64
+ ): BackendContributionDescriptorV1<Host, Contribution> {
65
+ return { kind: "backend", ...descriptor };
66
+ }
67
+
68
+ /** `defineBackendContribution` with the host fixed to the gateway. */
69
+ export function defineGatewayContribution<Host, Contribution>(
70
+ descriptor: Omit<
71
+ BackendContributionDescriptorV1<Host, Contribution>,
72
+ "kind" | "host"
73
+ >,
74
+ ): BackendContributionDescriptorV1<Host, Contribution> {
75
+ return { kind: "backend", host: "gateway", ...descriptor };
76
+ }
77
+
78
+ /** `defineBackendContribution` with the host fixed to the User Durable Object. */
79
+ export function defineUserBackendContribution<Host, Contribution>(
80
+ descriptor: Omit<
81
+ BackendContributionDescriptorV1<Host, Contribution>,
82
+ "kind" | "host"
83
+ >,
84
+ ): BackendContributionDescriptorV1<Host, Contribution> {
85
+ return { kind: "backend", host: "user", ...descriptor };
86
+ }
87
+
88
+ /** `defineBackendContribution` with the host fixed to the Bot Durable Object. */
89
+ export function defineBotBackendContribution<Host, Contribution>(
90
+ descriptor: Omit<
91
+ BackendContributionDescriptorV1<Host, Contribution>,
92
+ "kind" | "host"
93
+ >,
94
+ ): BackendContributionDescriptorV1<Host, Contribution> {
95
+ return { kind: "backend", host: "bot", ...descriptor };
96
+ }
97
+
98
+ export function defineClientContribution<ClientPlugin>(
99
+ descriptor: Omit<ClientContributionDescriptorV1<ClientPlugin>, "kind">,
100
+ ): ClientContributionDescriptorV1<ClientPlugin> {
101
+ return { kind: "client", ...descriptor };
102
+ }
@@ -1,15 +1,19 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  decodePackageIframeCatalogV1,
4
- decodePackageIframePageMessageV1,
4
+ decodePackageIframePageMessageV2,
5
5
  decodePackageIframeToolCommandV1,
6
+ iframePageSlotAllowedV1,
7
+ packageIframeExternalUrlAllowedV2,
8
+ packageIframeFocusAllowedV2,
6
9
  packageIframeToolAllowedV1,
10
+ PACKAGE_IFRAME_BRIDGE_VERSION,
7
11
  } from "./iframe-ui.js";
8
12
 
9
- describe("Package iframe bridge v1", () => {
13
+ describe("Package iframe bridge v2", () => {
10
14
  test("exactly decodes the two page-to-host messages", () => {
11
15
  expect(
12
- decodePackageIframePageMessageV1({
16
+ decodePackageIframePageMessageV2({
13
17
  schemaVersion: 1,
14
18
  type: "callTool",
15
19
  name: "weather_lookup",
@@ -17,14 +21,14 @@ describe("Package iframe bridge v1", () => {
17
21
  }),
18
22
  ).toMatchObject({ type: "callTool", name: "weather_lookup" });
19
23
  expect(
20
- decodePackageIframePageMessageV1({
24
+ decodePackageIframePageMessageV2({
21
25
  schemaVersion: 1,
22
26
  type: "resize",
23
27
  height: 320,
24
28
  }),
25
29
  ).toEqual({ schemaVersion: 1, type: "resize", height: 320 });
26
30
  expect(() =>
27
- decodePackageIframePageMessageV1({
31
+ decodePackageIframePageMessageV2({
28
32
  schemaVersion: 1,
29
33
  type: "resize",
30
34
  height: 320,
@@ -33,6 +37,105 @@ describe("Package iframe bridge v1", () => {
33
37
  ).toThrow("invalid fields");
34
38
  });
35
39
 
40
+ test("keeps a version 1 page working and adds the version 2 messages", () => {
41
+ // A v1 page's message decodes as v1 and is answered at v1, so a page
42
+ // published before this bridge existed is unaffected by the bump.
43
+ expect(
44
+ decodePackageIframePageMessageV2({
45
+ schemaVersion: 1,
46
+ type: "resize",
47
+ height: 120,
48
+ }).schemaVersion,
49
+ ).toBe(1);
50
+ expect(PACKAGE_IFRAME_BRIDGE_VERSION).toBe(2);
51
+ expect(
52
+ decodePackageIframePageMessageV2({
53
+ schemaVersion: 2,
54
+ type: "hello",
55
+ bridgeVersion: 2,
56
+ }),
57
+ ).toEqual({ schemaVersion: 2, type: "hello", bridgeVersion: 2 });
58
+ expect(
59
+ decodePackageIframePageMessageV2({
60
+ schemaVersion: 2,
61
+ type: "focus",
62
+ appletId: "todo.abc123",
63
+ }),
64
+ ).toEqual({ schemaVersion: 2, type: "focus", appletId: "todo.abc123" });
65
+ expect(
66
+ decodePackageIframePageMessageV2({
67
+ schemaVersion: 2,
68
+ type: "focus",
69
+ appletId: null,
70
+ }),
71
+ ).toEqual({ schemaVersion: 2, type: "focus", appletId: null });
72
+ expect(
73
+ decodePackageIframePageMessageV2({
74
+ schemaVersion: 2,
75
+ type: "openExternal",
76
+ url: "https://ui.example.com/packages/a.html",
77
+ }),
78
+ ).toMatchObject({ type: "openExternal" });
79
+ });
80
+
81
+ test("fails closed on v2 messages claiming version 1 and on bad payloads", () => {
82
+ // A page cannot reach a v2 capability by claiming the older version.
83
+ for (const type of ["hello", "focus", "openExternal"]) {
84
+ expect(() =>
85
+ decodePackageIframePageMessageV2({
86
+ schemaVersion: 1,
87
+ type,
88
+ appletId: null,
89
+ url: "https://ui.example.com/",
90
+ bridgeVersion: 1,
91
+ }),
92
+ ).toThrow("type is invalid");
93
+ }
94
+ expect(() =>
95
+ decodePackageIframePageMessageV2({
96
+ schemaVersion: 2,
97
+ type: "focus",
98
+ appletId: "Not An Applet",
99
+ }),
100
+ ).toThrow("appletId is invalid");
101
+ expect(() =>
102
+ decodePackageIframePageMessageV2({
103
+ schemaVersion: 2,
104
+ type: "openExternal",
105
+ url: "javascript:alert(1)",
106
+ }),
107
+ ).toThrow("url is invalid");
108
+ expect(() =>
109
+ decodePackageIframePageMessageV2({
110
+ schemaVersion: 3,
111
+ type: "resize",
112
+ height: 1,
113
+ }),
114
+ ).toThrow("schemaVersion is unsupported");
115
+ });
116
+
117
+ test("an external open is limited to the artifact origin", () => {
118
+ const origin = "https://ui.example.com";
119
+ expect(
120
+ packageIframeExternalUrlAllowedV2(`${origin}/packages/a.html`, origin),
121
+ ).toBe(true);
122
+ expect(
123
+ packageIframeExternalUrlAllowedV2("https://evil.example.com/", origin),
124
+ ).toBe(false);
125
+ expect(packageIframeExternalUrlAllowedV2("not a url", origin)).toBe(false);
126
+ });
127
+
128
+ test("only the Package that owns the focus tool may change the focus", () => {
129
+ expect(
130
+ packageIframeFocusAllowedV2({
131
+ declaredTools: ["applet_focus", "applet_list"],
132
+ }),
133
+ ).toBe(true);
134
+ expect(
135
+ packageIframeFocusAllowedV2({ declaredTools: ["weather_lookup"] }),
136
+ ).toBe(false);
137
+ });
138
+
36
139
  test("refuses a tool the Package did not declare", () => {
37
140
  const contribution = { declaredTools: ["weather_lookup"] };
38
141
  expect(packageIframeToolAllowedV1(contribution, "weather_lookup")).toBe(
@@ -58,42 +161,140 @@ describe("Package iframe bridge v1", () => {
58
161
  ).toThrow("invalid fields");
59
162
  });
60
163
 
61
- test("bounds projected HTML artifacts at the catalog seam", () => {
62
- const catalog = {
63
- schemaVersion: 1,
64
- botId: "bot",
65
- generationId: "generation-1",
66
- artifactOrigin: "https://ui.bot.frockbot.com",
67
- contributions: [
68
- {
69
- packageId: "weather-page",
70
- displayName: "Weather page",
71
- provenance: "Bot-authored",
72
- artifact: {
73
- contentHash: "a".repeat(64),
74
- size: 256 * 1024,
75
- mediaType: "text/html",
76
- bundlerVersion: "frockbot-inline-html@1",
164
+ const artifact = (hash: string) => ({
165
+ contentHash: hash.repeat(64),
166
+ size: 256 * 1024,
167
+ mediaType: "text/html",
168
+ bundlerVersion: "frockbot-inline-html@1",
169
+ });
170
+
171
+ const catalog = () => ({
172
+ schemaVersion: 1,
173
+ botId: "bot",
174
+ generationId: "generation-1",
175
+ artifactOrigin: "https://ui.bot.frockbot.com",
176
+ contributions: [
177
+ {
178
+ packageId: "weather-page",
179
+ displayName: "Weather page",
180
+ provenance: "Bot-authored",
181
+ pages: [
182
+ {
183
+ id: "main",
184
+ artifact: artifact("a"),
185
+ mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
77
186
  },
78
- mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
79
- declaredTools: ["weather_lookup"],
80
- },
81
- ],
82
- };
83
- expect(decodePackageIframeCatalogV1(catalog).contributions).toHaveLength(1);
84
- expect(() =>
85
- decodePackageIframeCatalogV1({
86
- ...catalog,
87
- contributions: [
88
187
  {
89
- ...catalog.contributions[0],
90
- artifact: {
91
- ...catalog.contributions[0]!.artifact,
92
- size: 256 * 1024 + 1,
93
- },
188
+ id: "board",
189
+ artifact: artifact("b"),
190
+ mounts: [
191
+ { slot: "frockbot.surface:board" },
192
+ { slot: "frockbot.right-panel" },
193
+ ],
94
194
  },
95
195
  ],
96
- }),
196
+ entries: [
197
+ {
198
+ id: "open",
199
+ slot: "frockbot.sidebar-actions",
200
+ order: 5,
201
+ label: "Weather board",
202
+ icon: "sparkle",
203
+ opens: { kind: "surface", page: "board" },
204
+ },
205
+ ],
206
+ declaredTools: ["weather_lookup"],
207
+ },
208
+ ],
209
+ });
210
+
211
+ const withContribution = (patch: Record<string, unknown>) => ({
212
+ ...catalog(),
213
+ contributions: [{ ...catalog().contributions[0]!, ...patch }],
214
+ });
215
+
216
+ test("bounds projected HTML artifacts at the catalog seam", () => {
217
+ const decoded = decodePackageIframeCatalogV1(catalog());
218
+ expect(decoded.contributions).toHaveLength(1);
219
+ expect(decoded.contributions[0]!.pages.map((page) => page.id)).toEqual([
220
+ "main",
221
+ "board",
222
+ ]);
223
+ expect(decoded.contributions[0]!.entries).toHaveLength(1);
224
+ expect(() =>
225
+ decodePackageIframeCatalogV1(
226
+ withContribution({
227
+ pages: [
228
+ {
229
+ ...catalog().contributions[0]!.pages[0]!,
230
+ artifact: { ...artifact("a"), size: 256 * 1024 + 1 },
231
+ },
232
+ ],
233
+ entries: [],
234
+ }),
235
+ ),
97
236
  ).toThrow("metadata is invalid");
98
237
  });
238
+
239
+ test("refuses catalog pages and entries outside the manifest rules", () => {
240
+ expect(() =>
241
+ decodePackageIframeCatalogV1(withContribution({ pages: [] })),
242
+ ).toThrow("non-empty bounded array");
243
+ expect(() =>
244
+ decodePackageIframeCatalogV1(
245
+ withContribution({
246
+ pages: [
247
+ {
248
+ id: "main",
249
+ artifact: artifact("a"),
250
+ mounts: [{ slot: "frockbot.surface:absent" }],
251
+ },
252
+ ],
253
+ entries: [],
254
+ }),
255
+ ),
256
+ ).toThrow("unsafe slot");
257
+ expect(() =>
258
+ decodePackageIframeCatalogV1(
259
+ withContribution({
260
+ entries: [
261
+ {
262
+ ...catalog().contributions[0]!.entries[0]!,
263
+ opens: { kind: "surface", page: "main" },
264
+ },
265
+ ],
266
+ }),
267
+ ),
268
+ ).toThrow("names no surface page");
269
+ expect(() =>
270
+ decodePackageIframeCatalogV1(
271
+ withContribution({
272
+ pages: [
273
+ catalog().contributions[0]!.pages[0]!,
274
+ { ...catalog().contributions[0]!.pages[1]!, id: "main" },
275
+ ],
276
+ }),
277
+ ),
278
+ ).toThrow("duplicate ids");
279
+ });
280
+
281
+ test("names one slot rule for every iframe seam", () => {
282
+ const context = { declaredTools: ["weather_lookup"], pageIds: ["board"] };
283
+ for (const slot of [
284
+ "frockbot.bot-settings-sections",
285
+ "frockbot.right-panel",
286
+ "frockbot.tool-result:weather_lookup",
287
+ "frockbot.surface:board",
288
+ ]) {
289
+ expect(iframePageSlotAllowedV1(slot, context)).toBe(true);
290
+ }
291
+ for (const slot of [
292
+ "root",
293
+ "frockbot.sidebar-actions",
294
+ "frockbot.tool-result:package_author",
295
+ "frockbot.surface:list",
296
+ ]) {
297
+ expect(iframePageSlotAllowedV1(slot, context)).toBe(false);
298
+ }
299
+ });
99
300
  });