@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,230 @@
|
|
|
1
|
+
import { CAPABILITY_NAMES, isCapabilityName } from "./capabilities.js";
|
|
2
|
+
/**
|
|
3
|
+
* Checks a config before anything is derived from it. Every string that ends up in a CSP, an
|
|
4
|
+
* attribute or the shell is checked here, so a malformed origin can't inject a directive
|
|
5
|
+
* (`https://a.com; script-src *`) and a typo can't widen a policy. A config that fails is refused
|
|
6
|
+
* whole, never repaired.
|
|
7
|
+
*/
|
|
8
|
+
export class ConfigError extends Error {
|
|
9
|
+
path;
|
|
10
|
+
constructor(path, message) {
|
|
11
|
+
super(`${path}: ${message}`);
|
|
12
|
+
this.name = "ConfigError";
|
|
13
|
+
this.path = path;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The built-in manual topics; a host's own topics may not reuse these names. */
|
|
17
|
+
export const BUILT_IN_TOPICS = ["widget", "chart", "interactive"];
|
|
18
|
+
const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])$|\.localhost$/u;
|
|
19
|
+
const HOST = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
|
|
20
|
+
/**
|
|
21
|
+
* Whether `value` is a CSP host source Porthole accepts: a scheme, a host (optionally `*.` for
|
|
22
|
+
* every subdomain) and an optional port, nothing else. Insecure schemes only for loopback names.
|
|
23
|
+
*/
|
|
24
|
+
export function isOriginSource(value) {
|
|
25
|
+
if (typeof value !== "string" || value.length > 253 + 16) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const match = /^(https|http|wss|ws):\/\/([^/:]+|\[::1\])(:(\d{1,5}))?$/u.exec(value);
|
|
29
|
+
if (match === null) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const [, scheme, host = "", , port] = match;
|
|
33
|
+
if (port !== undefined && (Number(port) < 1 || Number(port) > 65_535)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (host !== "[::1]" && !HOST.test(host)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const fail = (path, message) => {
|
|
45
|
+
throw new ConfigError(path, message);
|
|
46
|
+
};
|
|
47
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
48
|
+
function object(value, path) {
|
|
49
|
+
return isObject(value) ? value : fail(path, "must be an object");
|
|
50
|
+
}
|
|
51
|
+
function only(value, keys, path) {
|
|
52
|
+
for (const key of Object.keys(value)) {
|
|
53
|
+
if (!keys.includes(key)) {
|
|
54
|
+
fail(`${path}.${key}`, "is not a known field");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function sources(value, path) {
|
|
59
|
+
if (value === undefined) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(value)) {
|
|
63
|
+
fail(path, "must be an array of origins");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
value.forEach((source, index) => {
|
|
67
|
+
if (!isOriginSource(source)) {
|
|
68
|
+
fail(`${path}[${index}]`, `${JSON.stringify(source)} is not an origin (scheme://host[:port], lowercase, no path)`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function positive(value, path, max = Number.MAX_SAFE_INTEGER) {
|
|
73
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > max) {
|
|
74
|
+
fail(path, `must be an integer from 1 to ${max}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function gate(value, path) {
|
|
78
|
+
if (value === "user-activation" || value === "confirm") {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const limit = isObject(value) && isObject(value.rateLimit) ? value.rateLimit : null;
|
|
82
|
+
if (limit === null || Object.keys(value).length !== 1) {
|
|
83
|
+
fail(path, 'must be "user-activation", "confirm" or { rateLimit: { perMinute } }');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
only(limit, ["perMinute"], `${path}.rateLimit`);
|
|
87
|
+
positive(limit.perMinute, `${path}.rateLimit.perMinute`, 10_000);
|
|
88
|
+
}
|
|
89
|
+
const MODES = ["inline", "artifact", "mcp-app", "url"];
|
|
90
|
+
const ISOLATIONS = ["opaque", "domain", "external"];
|
|
91
|
+
/** Returns `input` as a config once every field checks out; throws a `ConfigError` otherwise. */
|
|
92
|
+
export function validateConfig(input) {
|
|
93
|
+
const config = object(input, "config");
|
|
94
|
+
only(config, [
|
|
95
|
+
"mode",
|
|
96
|
+
"isolation",
|
|
97
|
+
"capabilities",
|
|
98
|
+
"egress",
|
|
99
|
+
"theme",
|
|
100
|
+
"limits",
|
|
101
|
+
"layout",
|
|
102
|
+
"guide",
|
|
103
|
+
"contentOrigins",
|
|
104
|
+
"embedder",
|
|
105
|
+
], "config");
|
|
106
|
+
if (!MODES.includes(config.mode)) {
|
|
107
|
+
fail("config.mode", `must be one of ${MODES.join(", ")}`);
|
|
108
|
+
}
|
|
109
|
+
if (!ISOLATIONS.includes(config.isolation)) {
|
|
110
|
+
fail("config.isolation", `must be one of ${ISOLATIONS.join(", ")}`);
|
|
111
|
+
}
|
|
112
|
+
if ((config.mode === "url") !== (config.isolation === "external")) {
|
|
113
|
+
fail("config.isolation", 'url mode, and only url mode, is "external"');
|
|
114
|
+
}
|
|
115
|
+
const capabilities = object(config.capabilities, "config.capabilities");
|
|
116
|
+
for (const [name, value] of Object.entries(capabilities)) {
|
|
117
|
+
const path = `config.capabilities.${name}`;
|
|
118
|
+
if (!isCapabilityName(name)) {
|
|
119
|
+
fail(path, `is not a capability (${CAPABILITY_NAMES.join(", ")})`);
|
|
120
|
+
}
|
|
121
|
+
const capability = object(value, path);
|
|
122
|
+
only(capability, ["gates", "connect", "resource", "guide"], path);
|
|
123
|
+
if (capability.gates !== undefined) {
|
|
124
|
+
if (!Array.isArray(capability.gates)) {
|
|
125
|
+
fail(`${path}.gates`, "must be an array");
|
|
126
|
+
}
|
|
127
|
+
capability.gates.forEach((each, index) => gate(each, `${path}.gates[${index}]`));
|
|
128
|
+
}
|
|
129
|
+
sources(capability.connect, `${path}.connect`);
|
|
130
|
+
sources(capability.resource, `${path}.resource`);
|
|
131
|
+
if (capability.guide !== undefined && typeof capability.guide !== "string") {
|
|
132
|
+
fail(`${path}.guide`, "must be a string");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (config.mode === "url" && Object.keys(capabilities).length > 0) {
|
|
136
|
+
fail("config.capabilities", "url mode has no shell, so it can declare no capabilities");
|
|
137
|
+
}
|
|
138
|
+
const egress = object(config.egress, "config.egress");
|
|
139
|
+
const kinds = ["connect", "script", "style", "img", "font", "media"];
|
|
140
|
+
only(egress, kinds, "config.egress");
|
|
141
|
+
for (const kind of kinds) {
|
|
142
|
+
sources(egress[kind], `config.egress.${kind}`);
|
|
143
|
+
}
|
|
144
|
+
const theme = object(config.theme, "config.theme");
|
|
145
|
+
only(theme, ["variables", "event"], "config.theme");
|
|
146
|
+
const variables = object(theme.variables, "config.theme.variables");
|
|
147
|
+
for (const [name, value] of Object.entries(variables)) {
|
|
148
|
+
const path = `config.theme.variables[${JSON.stringify(name)}]`;
|
|
149
|
+
if (!/^--[a-z0-9]+(-[a-z0-9]+)*$/u.test(name)) {
|
|
150
|
+
fail(path, "must be a CSS custom property name (--lowercase-words)");
|
|
151
|
+
}
|
|
152
|
+
const values = isObject(value) ? [value.light, value.dark] : [value];
|
|
153
|
+
if (isObject(value)) {
|
|
154
|
+
only(value, ["light", "dark"], path);
|
|
155
|
+
}
|
|
156
|
+
for (const each of values) {
|
|
157
|
+
if (typeof each !== "string" ||
|
|
158
|
+
each.length === 0 ||
|
|
159
|
+
each.length > 500 ||
|
|
160
|
+
/[;{}<>]/u.test(each)) {
|
|
161
|
+
fail(path, "must be a CSS value (1–500 characters, no ; { } < >), or { light, dark } of them");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (typeof theme.event !== "string" || !/^[a-z][a-z0-9]*([:.-][a-z0-9]+)*$/u.test(theme.event)) {
|
|
166
|
+
fail("config.theme.event", 'must be an event name such as "porthole:theme"');
|
|
167
|
+
}
|
|
168
|
+
const limits = object(config.limits, "config.limits");
|
|
169
|
+
const limitNames = [
|
|
170
|
+
"maxHeightPx",
|
|
171
|
+
"minHeightPx",
|
|
172
|
+
"maxBytes",
|
|
173
|
+
"maxLiveFrames",
|
|
174
|
+
"declinesBeforeMute",
|
|
175
|
+
"maxPromptChars",
|
|
176
|
+
"maxErrorReports",
|
|
177
|
+
];
|
|
178
|
+
only(limits, limitNames, "config.limits");
|
|
179
|
+
for (const name of limitNames) {
|
|
180
|
+
positive(limits[name], `config.limits.${name}`);
|
|
181
|
+
}
|
|
182
|
+
if (limits.minHeightPx > limits.maxHeightPx) {
|
|
183
|
+
fail("config.limits.minHeightPx", "must not exceed maxHeightPx");
|
|
184
|
+
}
|
|
185
|
+
const layout = object(config.layout, "config.layout");
|
|
186
|
+
only(layout, ["minWidthPx", "narrowWidthPx", "wideWidthPx"], "config.layout");
|
|
187
|
+
for (const name of ["minWidthPx", "narrowWidthPx", "wideWidthPx"]) {
|
|
188
|
+
positive(layout[name], `config.layout.${name}`, 10_000);
|
|
189
|
+
}
|
|
190
|
+
if (layout.minWidthPx > layout.narrowWidthPx ||
|
|
191
|
+
layout.narrowWidthPx > layout.wideWidthPx) {
|
|
192
|
+
fail("config.layout", "must hold minWidthPx ≤ narrowWidthPx ≤ wideWidthPx");
|
|
193
|
+
}
|
|
194
|
+
const guide = object(config.guide, "config.guide");
|
|
195
|
+
only(guide, ["toolName", "guideToolName", "topics"], "config.guide");
|
|
196
|
+
for (const name of ["toolName", "guideToolName"]) {
|
|
197
|
+
const value = guide[name];
|
|
198
|
+
if (value !== undefined &&
|
|
199
|
+
(typeof value !== "string" || !/^[A-Za-z0-9_.-]{1,64}$/u.test(value))) {
|
|
200
|
+
fail(`config.guide.${name}`, "must be a tool name (letters, digits, _ . -)");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (guide.topics !== undefined) {
|
|
204
|
+
for (const [name, text] of Object.entries(object(guide.topics, "config.guide.topics"))) {
|
|
205
|
+
const path = `config.guide.topics.${name}`;
|
|
206
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/u.test(name) ||
|
|
207
|
+
BUILT_IN_TOPICS.includes(name)) {
|
|
208
|
+
fail(path, "must be a new topic name (lowercase), not a built-in one");
|
|
209
|
+
}
|
|
210
|
+
if (typeof text !== "string" || text.trim() === "") {
|
|
211
|
+
fail(path, "must be the topic's text");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
sources(config.contentOrigins, "config.contentOrigins");
|
|
216
|
+
const served = config.isolation === "domain" || config.isolation === "external";
|
|
217
|
+
const contentOrigins = config.contentOrigins;
|
|
218
|
+
if (served !== contentOrigins.length > 0) {
|
|
219
|
+
fail("config.contentOrigins", served
|
|
220
|
+
? `${String(config.isolation)} isolation needs the origins its content is served from`
|
|
221
|
+
: "opaque isolation serves nothing: leave it empty");
|
|
222
|
+
}
|
|
223
|
+
const embedder = object(config.embedder, "config.embedder");
|
|
224
|
+
only(embedder, ["frameSrc"], "config.embedder");
|
|
225
|
+
// The host's own frames may include its own origin, by name.
|
|
226
|
+
sources(Array.isArray(embedder.frameSrc)
|
|
227
|
+
? embedder.frameSrc.filter((source) => source !== "'self'")
|
|
228
|
+
: embedder.frameSrc, "config.embedder.frameSrc");
|
|
229
|
+
return input;
|
|
230
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { expect } from "@playwright/test";
|
|
2
|
+
import { presets } from "../presets/presets.js";
|
|
3
|
+
import { resolveTheme } from "../theme/variables.js";
|
|
4
|
+
import { inline } from "./kit.js";
|
|
5
|
+
import { settle } from "./oracles.js";
|
|
6
|
+
/**
|
|
7
|
+
* The display's behavior in a real frame (item 11: streaming, sizing, theming, base styles) and
|
|
8
|
+
* the host-function gates (item 12), each driven as a person or an agent would: real clicks, real
|
|
9
|
+
* streaming, real theme flips.
|
|
10
|
+
*/
|
|
11
|
+
const read = (frame, run) => frame.evaluate(run);
|
|
12
|
+
/** The computed value of a CSS variable on the frame's root. */
|
|
13
|
+
const variable = (frame, name) => frame.evaluate((each) => getComputedStyle(document.documentElement).getPropertyValue(each).trim(), name);
|
|
14
|
+
/** Polls the frame element's height until it is `px` (± 1). */
|
|
15
|
+
async function expectHeight(display, px, message) {
|
|
16
|
+
await expect
|
|
17
|
+
.poll(async () => Math.round((await display.box()).height), { message, timeout: 5_000 })
|
|
18
|
+
.toBeGreaterThanOrEqual(px - 1);
|
|
19
|
+
await expect
|
|
20
|
+
.poll(async () => Math.round((await display.box()).height), { message, timeout: 5_000 })
|
|
21
|
+
.toBeLessThanOrEqual(px + 1);
|
|
22
|
+
}
|
|
23
|
+
/** The refusals the display reported, by capability. */
|
|
24
|
+
async function refusals(display) {
|
|
25
|
+
return (await display.events()).flatMap((event) => event.type === "refused" ? [`${event.detail.capability}:${event.detail.refusal}`] : []);
|
|
26
|
+
}
|
|
27
|
+
/** A real click (a trusted event: the user activation a person's click gives). */
|
|
28
|
+
async function click(display, selector) {
|
|
29
|
+
await (await display.frame()).locator(selector).click();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Lets any user activation the test harness gave the host page lapse: Playwright's own
|
|
33
|
+
* evaluations count as gestures in some engines, and Chromium keeps one for 5 s. Between this and
|
|
34
|
+
* the next raw input, the test must not evaluate anything in the page.
|
|
35
|
+
*/
|
|
36
|
+
const ACTIVATION_LAPSE_MS = 6_000;
|
|
37
|
+
/** A click by raw mouse input at `selector`'s center, after any harness activation has lapsed. */
|
|
38
|
+
async function clickAfterLapse(context, display, selector) {
|
|
39
|
+
const frame = await display.frame();
|
|
40
|
+
const box = await frame.locator(selector).boundingBox();
|
|
41
|
+
if (box === null) {
|
|
42
|
+
throw new Error(`${selector} is not on screen`);
|
|
43
|
+
}
|
|
44
|
+
await context.page.waitForTimeout(ACTIVATION_LAPSE_MS);
|
|
45
|
+
await context.page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
|
46
|
+
}
|
|
47
|
+
/** The attribute `name` of the frame's body, once set. */
|
|
48
|
+
async function bodyData(display, name, timeout = 5_000) {
|
|
49
|
+
const frame = await display.frame();
|
|
50
|
+
await expect
|
|
51
|
+
.poll(() => frame.locator("body").getAttribute(`data-${name}`), { timeout })
|
|
52
|
+
.not.toBeNull();
|
|
53
|
+
return (await frame.locator("body").getAttribute(`data-${name}`)) ?? "";
|
|
54
|
+
}
|
|
55
|
+
const PROMPT_CONTENT = `<button id="ask">Explain March</button>
|
|
56
|
+
<script>
|
|
57
|
+
document.getElementById("ask").addEventListener("click", () => {
|
|
58
|
+
document.body.removeAttribute("data-result");
|
|
59
|
+
sendPrompt("Explain why signups doubled in March").then(
|
|
60
|
+
(sent) => (document.body.dataset.result = String(sent)),
|
|
61
|
+
(error) => (document.body.dataset.result = "refused: " + error.message),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
sendPrompt("asked on load").then(
|
|
65
|
+
() => (document.body.dataset.onload = "sent"),
|
|
66
|
+
(error) => (document.body.dataset.onload = "refused: " + error.message),
|
|
67
|
+
);
|
|
68
|
+
</script>`;
|
|
69
|
+
const prompts = () => inline({ capabilities: { sendPrompt: {} } });
|
|
70
|
+
export const BEHAVIOR_PROBES = [
|
|
71
|
+
// ── 11: streaming ──────────────────────────────────────────────────────────────────────────
|
|
72
|
+
{
|
|
73
|
+
id: "11.streaming",
|
|
74
|
+
item: 11,
|
|
75
|
+
title: "streamed content is drawn as it grows, inert, keeping what was drawn; the whole runs once",
|
|
76
|
+
async run(context) {
|
|
77
|
+
const display = await context.launch({ config: inline() });
|
|
78
|
+
const pixel = "data:image/gif;base64,R0lGODlhAQABAAAAACw=";
|
|
79
|
+
const head = `<style>.row { height: 20px }</style><img id="img" src="${pixel}" alt=""><div class="row" id="r1">one</div>`;
|
|
80
|
+
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>`;
|
|
81
|
+
const whole = `${step2}<script>document.body.dataset.ran = String((Number(document.body.dataset.ran) || 0) + 1)</script>`;
|
|
82
|
+
await display.setContent(head, { partial: true });
|
|
83
|
+
const frame = await display.frame();
|
|
84
|
+
await expect(frame.locator("#r1")).toHaveText("one");
|
|
85
|
+
// Mark what was drawn: the next draft must keep the same node (an image doesn't reload).
|
|
86
|
+
await frame.evaluate(() => {
|
|
87
|
+
document.getElementById("img").portholeMark =
|
|
88
|
+
1;
|
|
89
|
+
});
|
|
90
|
+
await display.setContent(step2, { partial: true });
|
|
91
|
+
await expect(frame.locator("#r2")).toHaveText("two");
|
|
92
|
+
expect(await read(frame, () => ({
|
|
93
|
+
kept: document.getElementById("img")
|
|
94
|
+
.portholeMark,
|
|
95
|
+
ran: document.body.dataset.ran ?? null,
|
|
96
|
+
href: document.getElementById("js")?.getAttribute("href") ?? null,
|
|
97
|
+
handler: document.getElementById("b")?.getAttribute("onclick") ?? null,
|
|
98
|
+
scripts: document.querySelectorAll("#porthole-root script").length,
|
|
99
|
+
}))).toEqual({ kept: 1, ran: null, href: null, handler: null, scripts: 0 });
|
|
100
|
+
await display.setContent(whole);
|
|
101
|
+
await expect(frame.locator("body")).toHaveAttribute("data-ran", "1");
|
|
102
|
+
expect(await frame.locator("#b").getAttribute("onclick")).not.toBeNull();
|
|
103
|
+
// The whole goes once: sending it again, or a late draft, changes nothing.
|
|
104
|
+
await display.setContent(whole);
|
|
105
|
+
await display.setContent(head, { partial: true });
|
|
106
|
+
await settle(400);
|
|
107
|
+
await expect(frame.locator("body")).toHaveAttribute("data-ran", "1");
|
|
108
|
+
await expect(frame.locator("#r2")).toHaveText("two");
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
// ── 11: sizing ─────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
{
|
|
113
|
+
id: "11.size",
|
|
114
|
+
item: 11,
|
|
115
|
+
title: "the frame's height follows its content, within the config's limits",
|
|
116
|
+
async run(context) {
|
|
117
|
+
const config = inline({ limits: { maxHeightPx: 400, minHeightPx: 30 } });
|
|
118
|
+
const display = await context.launch({
|
|
119
|
+
config,
|
|
120
|
+
content: `<div id="box" style="height: 150px"></div><script>window.setBox = (px) => (document.getElementById("box").style.height = px + "px");</script>`,
|
|
121
|
+
});
|
|
122
|
+
await expectHeight(display, 150, "follows its content");
|
|
123
|
+
const frame = await display.frame();
|
|
124
|
+
await frame.evaluate(() => window.setBox(300));
|
|
125
|
+
await expectHeight(display, 300, "grows with it");
|
|
126
|
+
await frame.evaluate(() => window.setBox(1_000));
|
|
127
|
+
await expectHeight(display, 400, "stops at maxHeightPx");
|
|
128
|
+
expect(await read(frame, () => document.documentElement.scrollHeight > document.documentElement.clientHeight), "past its height, the content scrolls inside").toBe(true);
|
|
129
|
+
await frame.evaluate(() => window.setBox(5));
|
|
130
|
+
await expectHeight(display, 30, "shrinks to minHeightPx");
|
|
131
|
+
const sizes = (await display.events()).flatMap((event) => event.type === "resize" ? [event.detail.height] : []);
|
|
132
|
+
// The first size is the content's: an empty shell reports none (it would make the host
|
|
133
|
+
// forget the height it remembered, and the frame jump).
|
|
134
|
+
expect(sizes[0]).toBe(150);
|
|
135
|
+
expect(sizes).toEqual(expect.arrayContaining([150, 300, 400, 30]));
|
|
136
|
+
expect(Math.max(...sizes)).toBe(400);
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "11.size-document",
|
|
141
|
+
item: 11,
|
|
142
|
+
title: "a whole page's frame follows the page's height, within the artifact's limits",
|
|
143
|
+
async run(context) {
|
|
144
|
+
const display = await context.launch({
|
|
145
|
+
config: presets.artifact(),
|
|
146
|
+
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>`,
|
|
147
|
+
});
|
|
148
|
+
await expectHeight(display, 500, "follows the page");
|
|
149
|
+
const frame = await display.frame();
|
|
150
|
+
await expect(frame.locator("#page")).toHaveText("page");
|
|
151
|
+
await expect(frame.locator("body"), "the page's own load event fires").toHaveAttribute("data-loaded", "1");
|
|
152
|
+
expect(await read(frame, () => document.title)).toBe("Report");
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
// ── 11: theming ────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
{
|
|
157
|
+
id: "11.theme-flip",
|
|
158
|
+
item: 11,
|
|
159
|
+
title: "a theme flip re-themes the running content in place, and tells it once",
|
|
160
|
+
async run(context) {
|
|
161
|
+
const config = inline({ theme: { event: "app:theme" } });
|
|
162
|
+
const light = resolveTheme(config.theme, "light");
|
|
163
|
+
const dark = resolveTheme(config.theme, "dark");
|
|
164
|
+
const display = await context.launch({
|
|
165
|
+
config,
|
|
166
|
+
scheme: "light",
|
|
167
|
+
content: `<p>themed</p><script>window.kept = "yes"; window.flips = []; addEventListener("app:theme", (event) => window.flips.push(event.detail.theme));</script>`,
|
|
168
|
+
});
|
|
169
|
+
const frame = await display.frame();
|
|
170
|
+
await expect(frame.locator("p")).toHaveText("themed");
|
|
171
|
+
expect(await variable(frame, "--color-text-primary")).toBe(light["--color-text-primary"]);
|
|
172
|
+
await display.setTheme("dark");
|
|
173
|
+
await expect
|
|
174
|
+
.poll(() => variable(frame, "--color-text-primary"))
|
|
175
|
+
.toBe(dark["--color-text-primary"]);
|
|
176
|
+
expect(await variable(frame, "--color-chart-1")).toBe(dark["--color-chart-1"]);
|
|
177
|
+
expect(await read(frame, () => ({
|
|
178
|
+
kept: window.kept,
|
|
179
|
+
flips: window.flips,
|
|
180
|
+
theme: document.documentElement.dataset.theme,
|
|
181
|
+
scheme: document.documentElement.style.colorScheme,
|
|
182
|
+
}))).toEqual({ kept: "yes", flips: ["dark"], theme: "dark", scheme: "dark" });
|
|
183
|
+
const element = await frame.frameElement();
|
|
184
|
+
expect(await element.evaluate((node) => node.style.colorScheme)).toBe("dark");
|
|
185
|
+
await display.setTheme("light");
|
|
186
|
+
await expect
|
|
187
|
+
.poll(() => variable(frame, "--color-text-primary"))
|
|
188
|
+
.toBe(light["--color-text-primary"]);
|
|
189
|
+
expect(await read(frame, () => window.flips)).toEqual([
|
|
190
|
+
"dark",
|
|
191
|
+
"light",
|
|
192
|
+
]);
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: "11.theme-host-values",
|
|
197
|
+
item: 11,
|
|
198
|
+
title: "the host's live values reach the frame, the spec's names and Porthole's own; others don't",
|
|
199
|
+
async run(context) {
|
|
200
|
+
const display = await context.launch({
|
|
201
|
+
config: inline(),
|
|
202
|
+
scheme: "dark",
|
|
203
|
+
hostTheme: {
|
|
204
|
+
dark: {
|
|
205
|
+
"--color-text-primary": "rgb(1, 2, 3)",
|
|
206
|
+
"--color-chart-1": "rgb(4, 5, 6)",
|
|
207
|
+
"--not-declared": "red",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
content: "<p>live</p>",
|
|
211
|
+
});
|
|
212
|
+
const frame = await display.frame();
|
|
213
|
+
await expect(frame.locator("p")).toHaveText("live");
|
|
214
|
+
await expect.poll(() => variable(frame, "--color-text-primary")).toBe("rgb(1, 2, 3)");
|
|
215
|
+
expect(await variable(frame, "--color-chart-1")).toBe("rgb(4, 5, 6)");
|
|
216
|
+
expect(await variable(frame, "--not-declared")).toBe("");
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: "11.base-styles",
|
|
221
|
+
item: 11,
|
|
222
|
+
title: "native controls take the theme, and content's own CSS overrides them",
|
|
223
|
+
async run(context) {
|
|
224
|
+
const config = inline();
|
|
225
|
+
const display = await context.launch({
|
|
226
|
+
config,
|
|
227
|
+
content: `<style>.square { border-radius: 0 }</style><button id="b">Go</button><button id="c" class="square">C</button><input id="i" value="x">`,
|
|
228
|
+
});
|
|
229
|
+
const frame = await display.frame();
|
|
230
|
+
await expect(frame.locator("#b")).toHaveText("Go");
|
|
231
|
+
const styles = await read(frame, () => {
|
|
232
|
+
const of = (id) => getComputedStyle(document.getElementById(id));
|
|
233
|
+
return {
|
|
234
|
+
radius: of("b").borderTopLeftRadius,
|
|
235
|
+
font: of("b").fontFamily === getComputedStyle(document.body).fontFamily,
|
|
236
|
+
height: document.getElementById("b").getBoundingClientRect().height,
|
|
237
|
+
overridden: of("c").borderTopLeftRadius,
|
|
238
|
+
input: of("i").borderTopLeftRadius,
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
expect(styles).toEqual({
|
|
242
|
+
radius: resolveTheme(config.theme, "light")["--border-radius-md"],
|
|
243
|
+
font: true,
|
|
244
|
+
height: expect.any(Number),
|
|
245
|
+
overridden: "0px",
|
|
246
|
+
input: resolveTheme(config.theme, "light")["--border-radius-md"],
|
|
247
|
+
});
|
|
248
|
+
expect(styles.height, "a control is tall enough to tap").toBeGreaterThanOrEqual(44);
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
// ── 12: the host-function gates ────────────────────────────────────────────────────────────
|
|
252
|
+
{
|
|
253
|
+
id: "12.prompt-gesture-confirm",
|
|
254
|
+
item: 12,
|
|
255
|
+
title: "sendPrompt needs a click and the person's yes; a no sends nothing",
|
|
256
|
+
async run(context) {
|
|
257
|
+
const display = await context.launch({ config: prompts(), content: PROMPT_CONTENT });
|
|
258
|
+
expect(await bodyData(display, "onload"), "a call made on load is refused by the shell").toContain("needs a click");
|
|
259
|
+
expect(await display.calls()).toEqual([]);
|
|
260
|
+
// The host's own check needs the frame's click to reach the host page (activation
|
|
261
|
+
// propagates to ancestors): measured with raw input, after the harness's activation lapsed.
|
|
262
|
+
await clickAfterLapse(context, display, "#ask");
|
|
263
|
+
await context.page.waitForTimeout(500);
|
|
264
|
+
const refused = await refusals(display);
|
|
265
|
+
context.measured(`${context.engine}: a click in the frame ${refused.includes("sendPrompt:no-user-activation") ? "does not reach" : "reaches"} the host page's user activation`);
|
|
266
|
+
expect(refused, "the frame's click didn't count as a gesture on the host page").toEqual([]);
|
|
267
|
+
const declined = await display.answerNextConfirmation(false);
|
|
268
|
+
expect(declined).toMatchObject({
|
|
269
|
+
capability: "sendPrompt",
|
|
270
|
+
args: { text: "Explain why signups doubled in March" },
|
|
271
|
+
display: { title: "Probe" },
|
|
272
|
+
});
|
|
273
|
+
expect(await bodyData(display, "result")).toBe("false");
|
|
274
|
+
expect(await display.calls(), "a declined prompt reached the host's handler").toEqual([]);
|
|
275
|
+
await click(display, "#ask");
|
|
276
|
+
await display.answerNextConfirmation(true);
|
|
277
|
+
expect(await bodyData(display, "result")).toBe("true");
|
|
278
|
+
expect(await display.calls()).toEqual([
|
|
279
|
+
{ capability: "sendPrompt", args: { text: "Explain why signups doubled in March" } },
|
|
280
|
+
]);
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
id: "12.raw-message",
|
|
285
|
+
item: 12,
|
|
286
|
+
title: "a prompt sent around the shell (raw ui/message, no click) is refused by the host",
|
|
287
|
+
async run(context) {
|
|
288
|
+
const display = await context.launch({
|
|
289
|
+
config: prompts(),
|
|
290
|
+
content: `<script>
|
|
291
|
+
addEventListener("message", (event) => {
|
|
292
|
+
if (event.data && event.data.id === "raw-1") document.body.dataset.answer = JSON.stringify(event.data.result ?? event.data.error);
|
|
293
|
+
});
|
|
294
|
+
// Late enough that no harness gesture is still active on the host page.
|
|
295
|
+
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});
|
|
296
|
+
</script>`,
|
|
297
|
+
});
|
|
298
|
+
// Nothing is evaluated in the page until the message has been answered.
|
|
299
|
+
await context.page.waitForTimeout(ACTIVATION_LAPSE_MS + 1_500);
|
|
300
|
+
expect(await bodyData(display, "answer")).toBe(JSON.stringify({ isError: true }));
|
|
301
|
+
expect(await display.calls()).toEqual([]);
|
|
302
|
+
expect(await refusals(display)).toEqual(["sendPrompt:no-user-activation"]);
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
id: "12.link",
|
|
307
|
+
item: 12,
|
|
308
|
+
title: "a link opens only through the host, http(s) only, with the person's yes",
|
|
309
|
+
async run(context) {
|
|
310
|
+
const display = await context.launch({
|
|
311
|
+
config: inline({ capabilities: { openLink: {} } }),
|
|
312
|
+
content: `<a id="web" href="https://example.com/report?q=1">report</a>
|
|
313
|
+
<a id="js" href="javascript:document.body.dataset.ran = 'yes'">js</a>
|
|
314
|
+
<button id="raw">raw</button>
|
|
315
|
+
<script>
|
|
316
|
+
document.getElementById("raw").addEventListener("click", () => openLink("javascript:alert(1)").then((opened) => (document.body.dataset.raw = String(opened)), () => (document.body.dataset.raw = "refused")));
|
|
317
|
+
</script>`,
|
|
318
|
+
});
|
|
319
|
+
await click(display, "#web");
|
|
320
|
+
const asked = await display.answerNextConfirmation(true);
|
|
321
|
+
expect(asked).toMatchObject({
|
|
322
|
+
capability: "openLink",
|
|
323
|
+
args: { url: "https://example.com/report?q=1" },
|
|
324
|
+
});
|
|
325
|
+
await expect
|
|
326
|
+
.poll(() => display.calls())
|
|
327
|
+
.toEqual([{ capability: "openLink", args: { url: "https://example.com/report?q=1" } }]);
|
|
328
|
+
await click(display, "#js");
|
|
329
|
+
await click(display, "#raw");
|
|
330
|
+
expect(await bodyData(display, "raw")).toBe("false");
|
|
331
|
+
await settle(300);
|
|
332
|
+
expect(await (await display.frame()).locator("body").getAttribute("data-ran")).toBeNull();
|
|
333
|
+
expect(await refusals(display)).toContain("openLink:invalid");
|
|
334
|
+
expect(await display.calls()).toHaveLength(1);
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
id: "12.mute",
|
|
339
|
+
item: 12,
|
|
340
|
+
title: "after the config's number of declines, the display stops asking",
|
|
341
|
+
async run(context) {
|
|
342
|
+
const display = await context.launch({
|
|
343
|
+
config: inline({ capabilities: { sendPrompt: {} }, limits: { declinesBeforeMute: 2 } }),
|
|
344
|
+
content: PROMPT_CONTENT,
|
|
345
|
+
});
|
|
346
|
+
for (let index = 0; index < 2; index += 1) {
|
|
347
|
+
await click(display, "#ask");
|
|
348
|
+
await display.answerNextConfirmation(false);
|
|
349
|
+
expect(await bodyData(display, "result")).toBe("false");
|
|
350
|
+
}
|
|
351
|
+
await expect
|
|
352
|
+
.poll(async () => (await display.events()).some((event) => event.type === "muted"))
|
|
353
|
+
.toBe(true);
|
|
354
|
+
await click(display, "#ask");
|
|
355
|
+
expect(await bodyData(display, "result")).toBe("false");
|
|
356
|
+
expect(await refusals(display)).toContain("sendPrompt:muted");
|
|
357
|
+
expect(await display.calls()).toEqual([]);
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
id: "12.no-confirm",
|
|
362
|
+
item: 12,
|
|
363
|
+
title: "a host without a confirm function sends nothing that needs one",
|
|
364
|
+
async run(context) {
|
|
365
|
+
const display = await context.launch({
|
|
366
|
+
config: prompts(),
|
|
367
|
+
confirms: false,
|
|
368
|
+
content: PROMPT_CONTENT,
|
|
369
|
+
});
|
|
370
|
+
await click(display, "#ask");
|
|
371
|
+
expect(await bodyData(display, "result")).toBe("false");
|
|
372
|
+
expect(await refusals(display)).toContain("sendPrompt:no-confirm");
|
|
373
|
+
expect(await display.calls()).toEqual([]);
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
];
|