@botiverse/porthole 0.0.1 → 0.1.0
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/LICENSE +217 -0
- package/dist/bridge/bridge.d.ts +39 -0
- package/dist/bridge/bridge.js +184 -0
- package/dist/bridge/types.d.ts +67 -0
- package/dist/bridge/types.js +1 -0
- package/dist/compile/compile.d.ts +35 -0
- package/dist/compile/compile.js +30 -0
- package/dist/compile/policy.d.ts +55 -0
- package/dist/compile/policy.js +106 -0
- package/dist/config/capabilities.d.ts +154 -0
- package/dist/config/capabilities.js +63 -0
- package/dist/config/schema.d.ts +131 -0
- package/dist/config/schema.js +1 -0
- package/dist/config/validate.d.ts +20 -0
- package/dist/config/validate.js +230 -0
- package/dist/conformance/behavior.d.ts +2 -0
- package/dist/conformance/behavior.js +376 -0
- package/dist/conformance/index.d.ts +95 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/kit.d.ts +42 -0
- package/dist/conformance/kit.js +24 -0
- package/dist/conformance/oracles.d.ts +35 -0
- package/dist/conformance/oracles.js +77 -0
- package/dist/conformance/probes.d.ts +9 -0
- package/dist/conformance/probes.js +865 -0
- package/dist/conformance/served.d.ts +68 -0
- package/dist/conformance/served.js +311 -0
- package/dist/guide/examples.d.ts +70 -0
- package/dist/guide/examples.js +69 -0
- package/dist/guide/guide.d.ts +16 -0
- package/dist/guide/guide.js +261 -0
- package/dist/host/create-host.d.ts +8 -0
- package/dist/host/create-host.js +248 -0
- package/dist/host/locate.d.ts +8 -0
- package/dist/host/locate.js +11 -0
- package/dist/host/types.d.ts +95 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +19 -0
- package/dist/isolation/served.d.ts +32 -0
- package/dist/isolation/served.js +97 -0
- package/dist/isolation/types.d.ts +87 -0
- package/dist/isolation/types.js +1 -0
- package/dist/mcp/adapter.d.ts +43 -0
- package/dist/mcp/adapter.js +327 -0
- package/dist/mcp/index.d.ts +27 -0
- package/dist/mcp/index.js +25 -0
- package/dist/mcp/proxy.d.ts +25 -0
- package/dist/mcp/proxy.js +106 -0
- package/dist/mcp/view.d.ts +31 -0
- package/dist/mcp/view.js +72 -0
- package/dist/platform/types.d.ts +44 -0
- package/dist/platform/types.js +1 -0
- package/dist/platform/web.d.ts +8 -0
- package/dist/platform/web.js +72 -0
- package/dist/presets/presets.d.ts +65 -0
- package/dist/presets/presets.js +125 -0
- package/dist/protocol/adapter.d.ts +73 -0
- package/dist/protocol/adapter.js +1 -0
- package/dist/protocol/jsonrpc.d.ts +43 -0
- package/dist/protocol/jsonrpc.js +50 -0
- package/dist/protocol/mcp-apps.d.ts +79 -0
- package/dist/protocol/mcp-apps.js +99 -0
- package/dist/protocol/shell-adapter.d.ts +5 -0
- package/dist/protocol/shell-adapter.js +228 -0
- package/dist/react/dialog.d.ts +20 -0
- package/dist/react/dialog.js +98 -0
- package/dist/react/frame.d.ts +75 -0
- package/dist/react/frame.js +221 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +13 -0
- package/dist/react/live.d.ts +24 -0
- package/dist/react/live.js +57 -0
- package/dist/server/index.d.ts +27 -0
- package/dist/server/index.js +26 -0
- package/dist/server/keys.d.ts +19 -0
- package/dist/server/keys.js +78 -0
- package/dist/server/pages.d.ts +6 -0
- package/dist/server/pages.js +20 -0
- package/dist/server/proxy.d.ts +21 -0
- package/dist/server/proxy.js +107 -0
- package/dist/server/server.d.ts +7 -0
- package/dist/server/server.js +518 -0
- package/dist/server/tickets.d.ts +8 -0
- package/dist/server/tickets.js +28 -0
- package/dist/server/types.d.ts +97 -0
- package/dist/server/types.js +1 -0
- package/dist/shell/document.d.ts +28 -0
- package/dist/shell/document.js +84 -0
- package/dist/shell/plan.d.ts +35 -0
- package/dist/shell/plan.js +34 -0
- package/dist/shell/runtime.d.ts +28 -0
- package/dist/shell/runtime.js +832 -0
- package/dist/theme/variables.d.ts +73 -0
- package/dist/theme/variables.js +171 -0
- package/dist/transport/post-message.d.ts +9 -0
- package/dist/transport/post-message.js +38 -0
- package/dist/transport/types.d.ts +15 -0
- package/dist/transport/types.js +1 -0
- package/package.json +71 -1
- package/src/bridge/bridge.ts +256 -0
- package/src/bridge/types.ts +77 -0
- package/src/compile/compile.ts +68 -0
- package/src/compile/policy.ts +133 -0
- package/src/config/capabilities.ts +138 -0
- package/src/config/schema.ts +140 -0
- package/src/config/validate.ts +284 -0
- package/src/conformance/behavior.ts +430 -0
- package/src/conformance/index.ts +102 -0
- package/src/conformance/kit.ts +63 -0
- package/src/conformance/oracles.ts +126 -0
- package/src/conformance/probes.ts +1093 -0
- package/src/conformance/served.ts +472 -0
- package/src/guide/examples.ts +74 -0
- package/src/guide/guide.ts +424 -0
- package/src/host/create-host.ts +290 -0
- package/src/host/locate.ts +20 -0
- package/src/host/types.ts +95 -0
- package/src/index.ts +118 -0
- package/src/isolation/served.ts +127 -0
- package/src/isolation/types.ts +77 -0
- package/src/mcp/adapter.ts +441 -0
- package/src/mcp/index.ts +50 -0
- package/src/mcp/proxy.ts +117 -0
- package/src/mcp/view.ts +97 -0
- package/src/platform/types.ts +48 -0
- package/src/platform/web.ts +78 -0
- package/src/presets/presets.ts +193 -0
- package/src/protocol/adapter.ts +67 -0
- package/src/protocol/jsonrpc.ts +83 -0
- package/src/protocol/mcp-apps.ts +138 -0
- package/src/protocol/shell-adapter.ts +261 -0
- package/src/react/dialog.tsx +158 -0
- package/src/react/frame.tsx +422 -0
- package/src/react/index.ts +20 -0
- package/src/react/live.ts +76 -0
- package/src/server/index.ts +37 -0
- package/src/server/keys.ts +100 -0
- package/src/server/pages.ts +31 -0
- package/src/server/proxy.ts +131 -0
- package/src/server/server.ts +617 -0
- package/src/server/tickets.ts +30 -0
- package/src/server/types.ts +104 -0
- package/src/shell/document.ts +120 -0
- package/src/shell/plan.ts +66 -0
- package/src/shell/runtime.ts +832 -0
- package/src/theme/variables.ts +190 -0
- package/src/transport/post-message.ts +45 -0
- package/src/transport/types.ts +16 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { expect, type Frame } from "@playwright/test";
|
|
2
|
+
import { presets } from "../presets/presets.ts";
|
|
3
|
+
import { resolveTheme } from "../theme/variables.ts";
|
|
4
|
+
import type { LaunchedDisplay } from "./index.ts";
|
|
5
|
+
import { inline, type Probe, type ProbeContext } from "./kit.ts";
|
|
6
|
+
import { settle } from "./oracles.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The display's behavior in a real frame (item 11: streaming, sizing, theming, base styles) and
|
|
10
|
+
* the host-function gates (item 12), each driven as a person or an agent would: real clicks, real
|
|
11
|
+
* streaming, real theme flips.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const read = <T>(frame: Frame, run: () => T): Promise<T> => frame.evaluate(run);
|
|
15
|
+
|
|
16
|
+
/** The computed value of a CSS variable on the frame's root. */
|
|
17
|
+
const variable = (frame: Frame, name: string): Promise<string> =>
|
|
18
|
+
frame.evaluate(
|
|
19
|
+
(each) => getComputedStyle(document.documentElement).getPropertyValue(each).trim(),
|
|
20
|
+
name,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
/** Polls the frame element's height until it is `px` (± 1). */
|
|
24
|
+
async function expectHeight(display: LaunchedDisplay, px: number, message: string): Promise<void> {
|
|
25
|
+
await expect
|
|
26
|
+
.poll(async () => Math.round((await display.box()).height), { message, timeout: 5_000 })
|
|
27
|
+
.toBeGreaterThanOrEqual(px - 1);
|
|
28
|
+
await expect
|
|
29
|
+
.poll(async () => Math.round((await display.box()).height), { message, timeout: 5_000 })
|
|
30
|
+
.toBeLessThanOrEqual(px + 1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The refusals the display reported, by capability. */
|
|
34
|
+
async function refusals(display: LaunchedDisplay): Promise<string[]> {
|
|
35
|
+
return (await display.events()).flatMap((event) =>
|
|
36
|
+
event.type === "refused" ? [`${event.detail.capability}:${event.detail.refusal}`] : [],
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A real click (a trusted event: the user activation a person's click gives). */
|
|
41
|
+
async function click(display: LaunchedDisplay, selector: string): Promise<void> {
|
|
42
|
+
await (await display.frame()).locator(selector).click();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Lets any user activation the test harness gave the host page lapse: Playwright's own
|
|
47
|
+
* evaluations count as gestures in some engines, and Chromium keeps one for 5 s. Between this and
|
|
48
|
+
* the next raw input, the test must not evaluate anything in the page.
|
|
49
|
+
*/
|
|
50
|
+
const ACTIVATION_LAPSE_MS = 6_000;
|
|
51
|
+
|
|
52
|
+
/** A click by raw mouse input at `selector`'s center, after any harness activation has lapsed. */
|
|
53
|
+
async function clickAfterLapse(
|
|
54
|
+
context: ProbeContext,
|
|
55
|
+
display: LaunchedDisplay,
|
|
56
|
+
selector: string,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const frame = await display.frame();
|
|
59
|
+
const box = await frame.locator(selector).boundingBox();
|
|
60
|
+
if (box === null) {
|
|
61
|
+
throw new Error(`${selector} is not on screen`);
|
|
62
|
+
}
|
|
63
|
+
await context.page.waitForTimeout(ACTIVATION_LAPSE_MS);
|
|
64
|
+
await context.page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The attribute `name` of the frame's body, once set. */
|
|
68
|
+
async function bodyData(display: LaunchedDisplay, name: string, timeout = 5_000): Promise<string> {
|
|
69
|
+
const frame = await display.frame();
|
|
70
|
+
await expect
|
|
71
|
+
.poll(() => frame.locator("body").getAttribute(`data-${name}`), { timeout })
|
|
72
|
+
.not.toBeNull();
|
|
73
|
+
return (await frame.locator("body").getAttribute(`data-${name}`)) ?? "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const PROMPT_CONTENT = `<button id="ask">Explain March</button>
|
|
77
|
+
<script>
|
|
78
|
+
document.getElementById("ask").addEventListener("click", () => {
|
|
79
|
+
document.body.removeAttribute("data-result");
|
|
80
|
+
sendPrompt("Explain why signups doubled in March").then(
|
|
81
|
+
(sent) => (document.body.dataset.result = String(sent)),
|
|
82
|
+
(error) => (document.body.dataset.result = "refused: " + error.message),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
sendPrompt("asked on load").then(
|
|
86
|
+
() => (document.body.dataset.onload = "sent"),
|
|
87
|
+
(error) => (document.body.dataset.onload = "refused: " + error.message),
|
|
88
|
+
);
|
|
89
|
+
</script>`;
|
|
90
|
+
|
|
91
|
+
const prompts = () => inline({ capabilities: { sendPrompt: {} } });
|
|
92
|
+
|
|
93
|
+
export const BEHAVIOR_PROBES: readonly Probe[] = [
|
|
94
|
+
// ── 11: streaming ──────────────────────────────────────────────────────────────────────────
|
|
95
|
+
{
|
|
96
|
+
id: "11.streaming",
|
|
97
|
+
item: 11,
|
|
98
|
+
title:
|
|
99
|
+
"streamed content is drawn as it grows, inert, keeping what was drawn; the whole runs once",
|
|
100
|
+
async run(context: ProbeContext) {
|
|
101
|
+
const display = await context.launch({ config: inline() });
|
|
102
|
+
const pixel = "data:image/gif;base64,R0lGODlhAQABAAAAACw=";
|
|
103
|
+
const head = `<style>.row { height: 20px }</style><img id="img" src="${pixel}" alt=""><div class="row" id="r1">one</div>`;
|
|
104
|
+
const step2 = `${head}<a id="js" href="javascript:document.body.dataset.ran='href'">x</a><button id="b" onclick="document.body.dataset.ran='handler'">b</button><div class="row" id="r2">two</div><script>document.body.dataset.ran = "draft"</script>`;
|
|
105
|
+
const whole = `${step2}<script>document.body.dataset.ran = String((Number(document.body.dataset.ran) || 0) + 1)</script>`;
|
|
106
|
+
|
|
107
|
+
await display.setContent(head, { partial: true });
|
|
108
|
+
const frame = await display.frame();
|
|
109
|
+
await expect(frame.locator("#r1")).toHaveText("one");
|
|
110
|
+
// Mark what was drawn: the next draft must keep the same node (an image doesn't reload).
|
|
111
|
+
await frame.evaluate(() => {
|
|
112
|
+
(document.getElementById("img") as HTMLElement & { portholeMark?: number }).portholeMark =
|
|
113
|
+
1;
|
|
114
|
+
});
|
|
115
|
+
await display.setContent(step2, { partial: true });
|
|
116
|
+
await expect(frame.locator("#r2")).toHaveText("two");
|
|
117
|
+
expect(
|
|
118
|
+
await read(frame, () => ({
|
|
119
|
+
kept: (document.getElementById("img") as HTMLElement & { portholeMark?: number })
|
|
120
|
+
.portholeMark,
|
|
121
|
+
ran: document.body.dataset.ran ?? null,
|
|
122
|
+
href: document.getElementById("js")?.getAttribute("href") ?? null,
|
|
123
|
+
handler: document.getElementById("b")?.getAttribute("onclick") ?? null,
|
|
124
|
+
scripts: document.querySelectorAll("#porthole-root script").length,
|
|
125
|
+
})),
|
|
126
|
+
).toEqual({ kept: 1, ran: null, href: null, handler: null, scripts: 0 });
|
|
127
|
+
|
|
128
|
+
await display.setContent(whole);
|
|
129
|
+
await expect(frame.locator("body")).toHaveAttribute("data-ran", "1");
|
|
130
|
+
expect(await frame.locator("#b").getAttribute("onclick")).not.toBeNull();
|
|
131
|
+
// The whole goes once: sending it again, or a late draft, changes nothing.
|
|
132
|
+
await display.setContent(whole);
|
|
133
|
+
await display.setContent(head, { partial: true });
|
|
134
|
+
await settle(400);
|
|
135
|
+
await expect(frame.locator("body")).toHaveAttribute("data-ran", "1");
|
|
136
|
+
await expect(frame.locator("#r2")).toHaveText("two");
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// ── 11: sizing ─────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
{
|
|
142
|
+
id: "11.size",
|
|
143
|
+
item: 11,
|
|
144
|
+
title: "the frame's height follows its content, within the config's limits",
|
|
145
|
+
async run(context) {
|
|
146
|
+
const config = inline({ limits: { maxHeightPx: 400, minHeightPx: 30 } });
|
|
147
|
+
const display = await context.launch({
|
|
148
|
+
config,
|
|
149
|
+
content: `<div id="box" style="height: 150px"></div><script>window.setBox = (px) => (document.getElementById("box").style.height = px + "px");</script>`,
|
|
150
|
+
});
|
|
151
|
+
await expectHeight(display, 150, "follows its content");
|
|
152
|
+
const frame = await display.frame();
|
|
153
|
+
await frame.evaluate(() => (window as unknown as { setBox(px: number): void }).setBox(300));
|
|
154
|
+
await expectHeight(display, 300, "grows with it");
|
|
155
|
+
await frame.evaluate(() => (window as unknown as { setBox(px: number): void }).setBox(1_000));
|
|
156
|
+
await expectHeight(display, 400, "stops at maxHeightPx");
|
|
157
|
+
expect(
|
|
158
|
+
await read(
|
|
159
|
+
frame,
|
|
160
|
+
() => document.documentElement.scrollHeight > document.documentElement.clientHeight,
|
|
161
|
+
),
|
|
162
|
+
"past its height, the content scrolls inside",
|
|
163
|
+
).toBe(true);
|
|
164
|
+
await frame.evaluate(() => (window as unknown as { setBox(px: number): void }).setBox(5));
|
|
165
|
+
await expectHeight(display, 30, "shrinks to minHeightPx");
|
|
166
|
+
const sizes = (await display.events()).flatMap((event) =>
|
|
167
|
+
event.type === "resize" ? [event.detail.height] : [],
|
|
168
|
+
);
|
|
169
|
+
// The first size is the content's: an empty shell reports none (it would make the host
|
|
170
|
+
// forget the height it remembered, and the frame jump).
|
|
171
|
+
expect(sizes[0]).toBe(150);
|
|
172
|
+
expect(sizes).toEqual(expect.arrayContaining([150, 300, 400, 30]));
|
|
173
|
+
expect(Math.max(...sizes)).toBe(400);
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: "11.size-document",
|
|
178
|
+
item: 11,
|
|
179
|
+
title: "a whole page's frame follows the page's height, within the artifact's limits",
|
|
180
|
+
async run(context) {
|
|
181
|
+
const display = await context.launch({
|
|
182
|
+
config: presets.artifact(),
|
|
183
|
+
content: `<!doctype html><html><head><title>Report</title></head><body style="margin: 0"><div id="page" style="height: 500px">page</div><script>document.body.dataset.loaded = "0"; addEventListener("load", () => (document.body.dataset.loaded = "1"));</script></body></html>`,
|
|
184
|
+
});
|
|
185
|
+
await expectHeight(display, 500, "follows the page");
|
|
186
|
+
const frame = await display.frame();
|
|
187
|
+
await expect(frame.locator("#page")).toHaveText("page");
|
|
188
|
+
await expect(frame.locator("body"), "the page's own load event fires").toHaveAttribute(
|
|
189
|
+
"data-loaded",
|
|
190
|
+
"1",
|
|
191
|
+
);
|
|
192
|
+
expect(await read(frame, () => document.title)).toBe("Report");
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
// ── 11: theming ────────────────────────────────────────────────────────────────────────────
|
|
197
|
+
{
|
|
198
|
+
id: "11.theme-flip",
|
|
199
|
+
item: 11,
|
|
200
|
+
title: "a theme flip re-themes the running content in place, and tells it once",
|
|
201
|
+
async run(context) {
|
|
202
|
+
const config = inline({ theme: { event: "app:theme" } });
|
|
203
|
+
const light = resolveTheme(config.theme, "light");
|
|
204
|
+
const dark = resolveTheme(config.theme, "dark");
|
|
205
|
+
const display = await context.launch({
|
|
206
|
+
config,
|
|
207
|
+
scheme: "light",
|
|
208
|
+
content: `<p>themed</p><script>window.kept = "yes"; window.flips = []; addEventListener("app:theme", (event) => window.flips.push(event.detail.theme));</script>`,
|
|
209
|
+
});
|
|
210
|
+
const frame = await display.frame();
|
|
211
|
+
await expect(frame.locator("p")).toHaveText("themed");
|
|
212
|
+
expect(await variable(frame, "--color-text-primary")).toBe(light["--color-text-primary"]);
|
|
213
|
+
await display.setTheme("dark");
|
|
214
|
+
await expect
|
|
215
|
+
.poll(() => variable(frame, "--color-text-primary"))
|
|
216
|
+
.toBe(dark["--color-text-primary"]);
|
|
217
|
+
expect(await variable(frame, "--color-chart-1")).toBe(dark["--color-chart-1"]);
|
|
218
|
+
expect(
|
|
219
|
+
await read(frame, () => ({
|
|
220
|
+
kept: (window as unknown as { kept?: string }).kept,
|
|
221
|
+
flips: (window as unknown as { flips: string[] }).flips,
|
|
222
|
+
theme: document.documentElement.dataset.theme,
|
|
223
|
+
scheme: document.documentElement.style.colorScheme,
|
|
224
|
+
})),
|
|
225
|
+
).toEqual({ kept: "yes", flips: ["dark"], theme: "dark", scheme: "dark" });
|
|
226
|
+
const element = await frame.frameElement();
|
|
227
|
+
expect(await element.evaluate((node) => (node as HTMLElement).style.colorScheme)).toBe(
|
|
228
|
+
"dark",
|
|
229
|
+
);
|
|
230
|
+
await display.setTheme("light");
|
|
231
|
+
await expect
|
|
232
|
+
.poll(() => variable(frame, "--color-text-primary"))
|
|
233
|
+
.toBe(light["--color-text-primary"]);
|
|
234
|
+
expect(await read(frame, () => (window as unknown as { flips: string[] }).flips)).toEqual([
|
|
235
|
+
"dark",
|
|
236
|
+
"light",
|
|
237
|
+
]);
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: "11.theme-host-values",
|
|
242
|
+
item: 11,
|
|
243
|
+
title:
|
|
244
|
+
"the host's live values reach the frame, the spec's names and Porthole's own; others don't",
|
|
245
|
+
async run(context) {
|
|
246
|
+
const display = await context.launch({
|
|
247
|
+
config: inline(),
|
|
248
|
+
scheme: "dark",
|
|
249
|
+
hostTheme: {
|
|
250
|
+
dark: {
|
|
251
|
+
"--color-text-primary": "rgb(1, 2, 3)",
|
|
252
|
+
"--color-chart-1": "rgb(4, 5, 6)",
|
|
253
|
+
"--not-declared": "red",
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
content: "<p>live</p>",
|
|
257
|
+
});
|
|
258
|
+
const frame = await display.frame();
|
|
259
|
+
await expect(frame.locator("p")).toHaveText("live");
|
|
260
|
+
await expect.poll(() => variable(frame, "--color-text-primary")).toBe("rgb(1, 2, 3)");
|
|
261
|
+
expect(await variable(frame, "--color-chart-1")).toBe("rgb(4, 5, 6)");
|
|
262
|
+
expect(await variable(frame, "--not-declared")).toBe("");
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: "11.base-styles",
|
|
267
|
+
item: 11,
|
|
268
|
+
title: "native controls take the theme, and content's own CSS overrides them",
|
|
269
|
+
async run(context) {
|
|
270
|
+
const config = inline();
|
|
271
|
+
const display = await context.launch({
|
|
272
|
+
config,
|
|
273
|
+
content: `<style>.square { border-radius: 0 }</style><button id="b">Go</button><button id="c" class="square">C</button><input id="i" value="x">`,
|
|
274
|
+
});
|
|
275
|
+
const frame = await display.frame();
|
|
276
|
+
await expect(frame.locator("#b")).toHaveText("Go");
|
|
277
|
+
const styles = await read(frame, () => {
|
|
278
|
+
const of = (id: string) => getComputedStyle(document.getElementById(id) as HTMLElement);
|
|
279
|
+
return {
|
|
280
|
+
radius: of("b").borderTopLeftRadius,
|
|
281
|
+
font: of("b").fontFamily === getComputedStyle(document.body).fontFamily,
|
|
282
|
+
height: (document.getElementById("b") as HTMLElement).getBoundingClientRect().height,
|
|
283
|
+
overridden: of("c").borderTopLeftRadius,
|
|
284
|
+
input: of("i").borderTopLeftRadius,
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
expect(styles).toEqual({
|
|
288
|
+
radius: resolveTheme(config.theme, "light")["--border-radius-md"],
|
|
289
|
+
font: true,
|
|
290
|
+
height: expect.any(Number),
|
|
291
|
+
overridden: "0px",
|
|
292
|
+
input: resolveTheme(config.theme, "light")["--border-radius-md"],
|
|
293
|
+
});
|
|
294
|
+
expect(styles.height, "a control is tall enough to tap").toBeGreaterThanOrEqual(44);
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
// ── 12: the host-function gates ────────────────────────────────────────────────────────────
|
|
299
|
+
{
|
|
300
|
+
id: "12.prompt-gesture-confirm",
|
|
301
|
+
item: 12,
|
|
302
|
+
title: "sendPrompt needs a click and the person's yes; a no sends nothing",
|
|
303
|
+
async run(context) {
|
|
304
|
+
const display = await context.launch({ config: prompts(), content: PROMPT_CONTENT });
|
|
305
|
+
expect(
|
|
306
|
+
await bodyData(display, "onload"),
|
|
307
|
+
"a call made on load is refused by the shell",
|
|
308
|
+
).toContain("needs a click");
|
|
309
|
+
expect(await display.calls()).toEqual([]);
|
|
310
|
+
|
|
311
|
+
// The host's own check needs the frame's click to reach the host page (activation
|
|
312
|
+
// propagates to ancestors): measured with raw input, after the harness's activation lapsed.
|
|
313
|
+
await clickAfterLapse(context, display, "#ask");
|
|
314
|
+
await context.page.waitForTimeout(500);
|
|
315
|
+
const refused = await refusals(display);
|
|
316
|
+
context.measured(
|
|
317
|
+
`${context.engine}: a click in the frame ${refused.includes("sendPrompt:no-user-activation") ? "does not reach" : "reaches"} the host page's user activation`,
|
|
318
|
+
);
|
|
319
|
+
expect(refused, "the frame's click didn't count as a gesture on the host page").toEqual([]);
|
|
320
|
+
const declined = await display.answerNextConfirmation(false);
|
|
321
|
+
expect(declined).toMatchObject({
|
|
322
|
+
capability: "sendPrompt",
|
|
323
|
+
args: { text: "Explain why signups doubled in March" },
|
|
324
|
+
display: { title: "Probe" },
|
|
325
|
+
});
|
|
326
|
+
expect(await bodyData(display, "result")).toBe("false");
|
|
327
|
+
expect(await display.calls(), "a declined prompt reached the host's handler").toEqual([]);
|
|
328
|
+
|
|
329
|
+
await click(display, "#ask");
|
|
330
|
+
await display.answerNextConfirmation(true);
|
|
331
|
+
expect(await bodyData(display, "result")).toBe("true");
|
|
332
|
+
expect(await display.calls()).toEqual([
|
|
333
|
+
{ capability: "sendPrompt", args: { text: "Explain why signups doubled in March" } },
|
|
334
|
+
]);
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
id: "12.raw-message",
|
|
339
|
+
item: 12,
|
|
340
|
+
title: "a prompt sent around the shell (raw ui/message, no click) is refused by the host",
|
|
341
|
+
async run(context) {
|
|
342
|
+
const display = await context.launch({
|
|
343
|
+
config: prompts(),
|
|
344
|
+
content: `<script>
|
|
345
|
+
addEventListener("message", (event) => {
|
|
346
|
+
if (event.data && event.data.id === "raw-1") document.body.dataset.answer = JSON.stringify(event.data.result ?? event.data.error);
|
|
347
|
+
});
|
|
348
|
+
// Late enough that no harness gesture is still active on the host page.
|
|
349
|
+
setTimeout(() => parent.postMessage({ jsonrpc: "2.0", id: "raw-1", method: "ui/message", params: { role: "user", content: [{ type: "text", text: "sent around the shell" }] } }, "*"), ${ACTIVATION_LAPSE_MS});
|
|
350
|
+
</script>`,
|
|
351
|
+
});
|
|
352
|
+
// Nothing is evaluated in the page until the message has been answered.
|
|
353
|
+
await context.page.waitForTimeout(ACTIVATION_LAPSE_MS + 1_500);
|
|
354
|
+
expect(await bodyData(display, "answer")).toBe(JSON.stringify({ isError: true }));
|
|
355
|
+
expect(await display.calls()).toEqual([]);
|
|
356
|
+
expect(await refusals(display)).toEqual(["sendPrompt:no-user-activation"]);
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
id: "12.link",
|
|
361
|
+
item: 12,
|
|
362
|
+
title: "a link opens only through the host, http(s) only, with the person's yes",
|
|
363
|
+
async run(context) {
|
|
364
|
+
const display = await context.launch({
|
|
365
|
+
config: inline({ capabilities: { openLink: {} } }),
|
|
366
|
+
content: `<a id="web" href="https://example.com/report?q=1">report</a>
|
|
367
|
+
<a id="js" href="javascript:document.body.dataset.ran = 'yes'">js</a>
|
|
368
|
+
<button id="raw">raw</button>
|
|
369
|
+
<script>
|
|
370
|
+
document.getElementById("raw").addEventListener("click", () => openLink("javascript:alert(1)").then((opened) => (document.body.dataset.raw = String(opened)), () => (document.body.dataset.raw = "refused")));
|
|
371
|
+
</script>`,
|
|
372
|
+
});
|
|
373
|
+
await click(display, "#web");
|
|
374
|
+
const asked = await display.answerNextConfirmation(true);
|
|
375
|
+
expect(asked).toMatchObject({
|
|
376
|
+
capability: "openLink",
|
|
377
|
+
args: { url: "https://example.com/report?q=1" },
|
|
378
|
+
});
|
|
379
|
+
await expect
|
|
380
|
+
.poll(() => display.calls())
|
|
381
|
+
.toEqual([{ capability: "openLink", args: { url: "https://example.com/report?q=1" } }]);
|
|
382
|
+
await click(display, "#js");
|
|
383
|
+
await click(display, "#raw");
|
|
384
|
+
expect(await bodyData(display, "raw")).toBe("false");
|
|
385
|
+
await settle(300);
|
|
386
|
+
expect(await (await display.frame()).locator("body").getAttribute("data-ran")).toBeNull();
|
|
387
|
+
expect(await refusals(display)).toContain("openLink:invalid");
|
|
388
|
+
expect(await display.calls()).toHaveLength(1);
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
id: "12.mute",
|
|
393
|
+
item: 12,
|
|
394
|
+
title: "after the config's number of declines, the display stops asking",
|
|
395
|
+
async run(context) {
|
|
396
|
+
const display = await context.launch({
|
|
397
|
+
config: inline({ capabilities: { sendPrompt: {} }, limits: { declinesBeforeMute: 2 } }),
|
|
398
|
+
content: PROMPT_CONTENT,
|
|
399
|
+
});
|
|
400
|
+
for (let index = 0; index < 2; index += 1) {
|
|
401
|
+
await click(display, "#ask");
|
|
402
|
+
await display.answerNextConfirmation(false);
|
|
403
|
+
expect(await bodyData(display, "result")).toBe("false");
|
|
404
|
+
}
|
|
405
|
+
await expect
|
|
406
|
+
.poll(async () => (await display.events()).some((event) => event.type === "muted"))
|
|
407
|
+
.toBe(true);
|
|
408
|
+
await click(display, "#ask");
|
|
409
|
+
expect(await bodyData(display, "result")).toBe("false");
|
|
410
|
+
expect(await refusals(display)).toContain("sendPrompt:muted");
|
|
411
|
+
expect(await display.calls()).toEqual([]);
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
id: "12.no-confirm",
|
|
416
|
+
item: 12,
|
|
417
|
+
title: "a host without a confirm function sends nothing that needs one",
|
|
418
|
+
async run(context) {
|
|
419
|
+
const display = await context.launch({
|
|
420
|
+
config: prompts(),
|
|
421
|
+
confirms: false,
|
|
422
|
+
content: PROMPT_CONTENT,
|
|
423
|
+
});
|
|
424
|
+
await click(display, "#ask");
|
|
425
|
+
expect(await bodyData(display, "result")).toBe("false");
|
|
426
|
+
expect(await refusals(display)).toContain("sendPrompt:no-confirm");
|
|
427
|
+
expect(await display.calls()).toEqual([]);
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@botiverse/porthole/conformance`: the leak/conformance suite (P008), runnable by any host
|
|
3
|
+
* against its own integration. A host implements `ConformanceHost` (how to show one display in a
|
|
4
|
+
* page and drive it), then registers the suite in its Playwright project:
|
|
5
|
+
*
|
|
6
|
+
* import { test } from "@playwright/test";
|
|
7
|
+
* import { defineLeakSuite } from "@botiverse/porthole/conformance";
|
|
8
|
+
* defineLeakSuite(test, myHost);
|
|
9
|
+
*
|
|
10
|
+
* Each probe attempts one egress or escape channel (DESIGN.md § Egress hardening) and asserts,
|
|
11
|
+
* with an out-of-band oracle where one exists (an HTTP canary that also counts connections, a UDP
|
|
12
|
+
* listener for ICE), that nothing left. Each oracle has a positive control: where the engine
|
|
13
|
+
* can't make the channel work even unsandboxed, the probe says so instead of passing silently.
|
|
14
|
+
* Porthole runs it against its own reference host on every engine it supports.
|
|
15
|
+
*/
|
|
16
|
+
import type { Frame, Page } from "@playwright/test";
|
|
17
|
+
import type { ConfirmRequest } from "../bridge/types.ts";
|
|
18
|
+
import type { CapabilityName } from "../config/capabilities.ts";
|
|
19
|
+
import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
|
|
20
|
+
import type { DisplayEvents } from "../host/types.ts";
|
|
21
|
+
|
|
22
|
+
export interface LaunchSpec {
|
|
23
|
+
readonly config: PortholeConfig;
|
|
24
|
+
/**
|
|
25
|
+
* The whole content (a fragment, or a document for artifact mode). Omitted: nothing is sent,
|
|
26
|
+
* and the test drives the content with `LaunchedDisplay.setContent`.
|
|
27
|
+
*/
|
|
28
|
+
readonly content?: string;
|
|
29
|
+
/** Content to stream first, each step sent as partial, before the whole. */
|
|
30
|
+
readonly partials?: readonly string[];
|
|
31
|
+
/** A whole document the frame starts with instead (an MCP Apps view). */
|
|
32
|
+
readonly document?: string;
|
|
33
|
+
readonly title?: string;
|
|
34
|
+
readonly scheme?: ColorScheme;
|
|
35
|
+
/** The frame's width, px. */
|
|
36
|
+
readonly widthPx?: number;
|
|
37
|
+
/** The host's live values for theme variables, per scheme (as `HostOptions.theme` answers). */
|
|
38
|
+
readonly hostTheme?: {
|
|
39
|
+
readonly light?: Readonly<Record<string, string>>;
|
|
40
|
+
readonly dark?: Readonly<Record<string, string>>;
|
|
41
|
+
};
|
|
42
|
+
/** Whether the host has a `confirm` function (default true): without one, gated calls are refused. */
|
|
43
|
+
readonly confirms?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RecordedCall {
|
|
47
|
+
readonly capability: CapabilityName;
|
|
48
|
+
readonly args: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type RecordedEvent = {
|
|
52
|
+
[E in keyof DisplayEvents]: { readonly type: E; readonly detail: DisplayEvents[E] };
|
|
53
|
+
}[keyof DisplayEvents];
|
|
54
|
+
|
|
55
|
+
export interface LaunchedDisplay {
|
|
56
|
+
/** The display's frame as it is now (a restart makes a new one), to probe from inside. */
|
|
57
|
+
frame(): Promise<Frame>;
|
|
58
|
+
/** The frame element's size on the host's page, px. */
|
|
59
|
+
box(): Promise<{ readonly width: number; readonly height: number }>;
|
|
60
|
+
/** Sends content: partial (drawn inert) any number of times, then whole (runs once). */
|
|
61
|
+
setContent(html: string, options?: { readonly partial?: boolean }): Promise<void>;
|
|
62
|
+
setTheme(scheme: ColorScheme): Promise<void>;
|
|
63
|
+
/** Waits for the next confirmation the host is asked for, answers it, and returns it. */
|
|
64
|
+
answerNextConfirmation(
|
|
65
|
+
accept: boolean,
|
|
66
|
+
options?: { readonly timeoutMs?: number },
|
|
67
|
+
): Promise<ConfirmRequest>;
|
|
68
|
+
/** The calls that reached the host's handlers, in order. */
|
|
69
|
+
calls(): Promise<readonly RecordedCall[]>;
|
|
70
|
+
/** The errors the display reported. */
|
|
71
|
+
errors(): Promise<readonly string[]>;
|
|
72
|
+
/** Every event the display emitted, in order. */
|
|
73
|
+
events(): Promise<readonly RecordedEvent[]>;
|
|
74
|
+
destroy(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What a host implements to run the suite against its integration. */
|
|
78
|
+
export interface ConformanceHost {
|
|
79
|
+
readonly name: string;
|
|
80
|
+
/**
|
|
81
|
+
* Shows one display in `page` and returns handles to drive it. The first call on a page loads
|
|
82
|
+
* the host's page (sending its embedding CSP for `spec.config`); a later call on the same page
|
|
83
|
+
* adds another display beside the first.
|
|
84
|
+
*/
|
|
85
|
+
launch(page: Page, spec: LaunchSpec): Promise<LaunchedDisplay>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface LeakSuiteOptions {
|
|
89
|
+
/** Probe ids to skip, each with the reason (shown in the report). */
|
|
90
|
+
readonly skip?: Readonly<Record<string, string>>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { defineLeakSuite, PROBES, type Probe } from "./probes.ts";
|
|
94
|
+
export { httpOracle, settle, udpOracle, type HttpOracle, type UdpOracle } from "./oracles.ts";
|
|
95
|
+
|
|
96
|
+
// The served modes' cases (domain and url mode), with their own host interface.
|
|
97
|
+
export {
|
|
98
|
+
defineServedSuite,
|
|
99
|
+
type ServedConformanceHost,
|
|
100
|
+
type ServedDisplay,
|
|
101
|
+
type ServedSuiteOptions,
|
|
102
|
+
} from "./served.ts";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Page } from "@playwright/test";
|
|
2
|
+
import { presets, type PresetOptions } from "../presets/presets.ts";
|
|
3
|
+
import type { LaunchedDisplay, LaunchSpec } from "./index.ts";
|
|
4
|
+
import type { HttpOracle, ServedFile, UdpOracle } from "./oracles.ts";
|
|
5
|
+
|
|
6
|
+
/** What every probe is given, and the helpers they share. */
|
|
7
|
+
|
|
8
|
+
export interface ProbeContext {
|
|
9
|
+
readonly page: Page;
|
|
10
|
+
readonly engine: string;
|
|
11
|
+
launch(spec: LaunchSpec): Promise<LaunchedDisplay>;
|
|
12
|
+
/** A fresh HTTP oracle (serving `files` at their paths), closed after the probe. */
|
|
13
|
+
http(files?: Readonly<Record<string, ServedFile>>): Promise<HttpOracle>;
|
|
14
|
+
/** A fresh UDP oracle, closed after the probe. */
|
|
15
|
+
udp(): Promise<UdpOracle>;
|
|
16
|
+
/** Waits for the content's verdict (`window.portholeProbe`, set by `attack`) and returns it. */
|
|
17
|
+
result(display: LaunchedDisplay, timeoutMs?: number): Promise<Record<string, unknown>>;
|
|
18
|
+
/**
|
|
19
|
+
* The URLs on `origin` that answered the page (any of its frames): requests that left the
|
|
20
|
+
* browser and came back. A load the browser blocked itself never has a response.
|
|
21
|
+
*/
|
|
22
|
+
requestsTo(origin: string): string[];
|
|
23
|
+
/** Records a measured engine fact in the report. */
|
|
24
|
+
measured(text: string): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Probe {
|
|
28
|
+
/** Stable id, `<item>.<channel>`: what `LeakSuiteOptions.skip` names. */
|
|
29
|
+
readonly id: string;
|
|
30
|
+
/**
|
|
31
|
+
* The DESIGN.md egress-table item it proves; 0 for the shell's own wiring and interop, 11 for
|
|
32
|
+
* behavior (streaming, sizing, theming), 12 for host-function gates.
|
|
33
|
+
*/
|
|
34
|
+
readonly item: number;
|
|
35
|
+
readonly title: string;
|
|
36
|
+
run(context: ProbeContext): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A JS literal safe inside an inline `<script>` (no `</script>` can end it early). */
|
|
40
|
+
export const js = (value: unknown): string => JSON.stringify(value).replace(/</gu, "\\u003c");
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Content that runs `code` (an async body with `r`, its verdict, in scope) once the shell has
|
|
44
|
+
* drawn it, then publishes `r` as `window.portholeProbe`. A `#marker` shows the content is still there.
|
|
45
|
+
*/
|
|
46
|
+
export function attack(code: string, markup = ""): string {
|
|
47
|
+
return `<div id="marker">content</div>${markup}
|
|
48
|
+
<script>
|
|
49
|
+
(async () => {
|
|
50
|
+
const r = {};
|
|
51
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
52
|
+
try {
|
|
53
|
+
${code}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
r.error = String((error && error.message) || error);
|
|
56
|
+
}
|
|
57
|
+
r.done = true;
|
|
58
|
+
window.portholeProbe = r;
|
|
59
|
+
})();
|
|
60
|
+
</script>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const inline = (options: PresetOptions = {}) => presets.inlineWidget(options);
|