@botiverse/porthole 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/bridge.js +20 -6
- package/dist/bridge/types.d.ts +19 -3
- package/dist/compile/policy.d.ts +3 -3
- package/dist/compile/policy.js +6 -9
- package/dist/config/schema.d.ts +21 -0
- package/dist/config/schema.js +11 -1
- package/dist/config/validate.d.ts +3 -1
- package/dist/config/validate.js +41 -10
- package/dist/conformance/behavior.js +50 -0
- package/dist/conformance/index.d.ts +11 -5
- package/dist/conformance/index.js +2 -2
- package/dist/conformance/probes.d.ts +26 -2
- package/dist/conformance/probes.js +51 -40
- package/dist/conformance/served.d.ts +7 -0
- package/dist/conformance/served.js +31 -7
- package/dist/guide/guide.js +6 -2
- package/dist/host/create-host.js +46 -2
- package/dist/host/locate.js +6 -1
- package/dist/host/outcome.d.ts +24 -0
- package/dist/host/outcome.js +134 -0
- package/dist/host/types.d.ts +34 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/isolation/served.d.ts +3 -0
- package/dist/isolation/served.js +20 -0
- package/dist/isolation/types.d.ts +9 -0
- package/dist/isolation/types.js +2 -1
- package/dist/platform/types.d.ts +7 -0
- package/dist/platform/web.js +15 -2
- package/dist/presets/presets.d.ts +9 -3
- package/dist/presets/presets.js +16 -1
- package/dist/react/frame.d.ts +16 -0
- package/dist/react/frame.js +44 -10
- package/dist/server/pages.d.ts +11 -6
- package/dist/server/pages.js +78 -12
- package/dist/server/server.js +34 -10
- package/dist/shell/document.d.ts +3 -2
- package/dist/shell/document.js +4 -3
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +1 -0
- package/dist/shell/runtime.js +20 -4
- package/package.json +1 -9
- package/src/bridge/bridge.ts +28 -6
- package/src/bridge/types.ts +21 -3
- package/src/compile/policy.ts +6 -9
- package/src/config/schema.ts +28 -0
- package/src/config/validate.ts +48 -14
- package/src/conformance/behavior.ts +55 -0
- package/src/conformance/index.ts +18 -4
- package/src/conformance/probes.ts +88 -52
- package/src/conformance/served.ts +37 -8
- package/src/guide/guide.ts +8 -2
- package/src/host/create-host.ts +47 -2
- package/src/host/locate.ts +6 -1
- package/src/host/outcome.ts +159 -0
- package/src/host/types.ts +33 -2
- package/src/index.ts +11 -1
- package/src/isolation/served.ts +24 -0
- package/src/isolation/types.ts +15 -1
- package/src/platform/types.ts +7 -0
- package/src/platform/web.ts +15 -2
- package/src/presets/presets.ts +27 -3
- package/src/react/frame.tsx +78 -14
- package/src/server/pages.ts +97 -11
- package/src/server/server.ts +48 -8
- package/src/shell/document.ts +7 -6
- package/src/shell/plan.ts +3 -0
- package/src/shell/runtime.ts +20 -4
package/dist/bridge/bridge.js
CHANGED
|
@@ -134,18 +134,26 @@ export function createBridge(options) {
|
|
|
134
134
|
return refuse(capability, "no-user-activation");
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
+
// The effect, run once: when the person's "yes" calls `proceed` (inside its own click, so an
|
|
138
|
+
// effect that needs a gesture gets one), or after `confirm` resolves accepted.
|
|
139
|
+
let effect = null;
|
|
140
|
+
const run = () => (effect ??= perform(capability, args));
|
|
137
141
|
if (confirms) {
|
|
138
142
|
if (options.confirm === undefined) {
|
|
139
143
|
return refuse(capability, "no-confirm");
|
|
140
144
|
}
|
|
141
|
-
let
|
|
145
|
+
let answer = false;
|
|
142
146
|
try {
|
|
143
|
-
|
|
147
|
+
answer = await options.confirm({ capability, args, display }, () => void run());
|
|
144
148
|
}
|
|
145
149
|
catch {
|
|
146
|
-
|
|
150
|
+
answer = effect === null ? false : "accepted";
|
|
147
151
|
}
|
|
148
|
-
if (
|
|
152
|
+
if (effect === null && answer !== true && answer !== "accepted") {
|
|
153
|
+
if (answer === "redirected") {
|
|
154
|
+
return refuse(capability, "redirected");
|
|
155
|
+
}
|
|
156
|
+
// Declines in a row mute the display; a yes starts the count again.
|
|
149
157
|
declines += 1;
|
|
150
158
|
if (declines >= config.limits.declinesBeforeMute && !muted) {
|
|
151
159
|
muted = true;
|
|
@@ -153,7 +161,13 @@ export function createBridge(options) {
|
|
|
153
161
|
}
|
|
154
162
|
return refuse(capability, "declined");
|
|
155
163
|
}
|
|
164
|
+
declines = 0;
|
|
156
165
|
}
|
|
166
|
+
return run();
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
async function perform(capability, args) {
|
|
170
|
+
{
|
|
157
171
|
if (capabilitySpec(capability).served === "event") {
|
|
158
172
|
if (capability === "resize") {
|
|
159
173
|
const size = args;
|
|
@@ -179,6 +193,6 @@ export function createBridge(options) {
|
|
|
179
193
|
catch (error) {
|
|
180
194
|
return refuse(capability, "failed", error instanceof Error ? error.message : String(error));
|
|
181
195
|
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
184
198
|
}
|
package/dist/bridge/types.d.ts
CHANGED
|
@@ -29,10 +29,24 @@ export type ConfirmRequest = {
|
|
|
29
29
|
};
|
|
30
30
|
}[CapabilityName];
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* What the person chose. `true`/`"accepted"`: the call goes. `false`/`"declined"`: it doesn't,
|
|
33
|
+
* and the decline counts toward muting the display. `"redirected"`: it doesn't go, but the person
|
|
34
|
+
* took it elsewhere (edited the prompt first, say), so it neither counts as a decline nor resets
|
|
35
|
+
* the count. The host decides which its UI's choices are.
|
|
34
36
|
*/
|
|
35
|
-
export type
|
|
37
|
+
export type ConfirmAnswer = boolean | "accepted" | "declined" | "redirected";
|
|
38
|
+
/**
|
|
39
|
+
* Runs the call's effect now, inside the caller's own gesture, and counts as accepted. A host
|
|
40
|
+
* whose effect needs the person's gesture (opening a window) calls it from the click handler of
|
|
41
|
+
* its own "yes" button, rather than resolving the promise and letting the effect run after.
|
|
42
|
+
*/
|
|
43
|
+
export type Proceed = () => void;
|
|
44
|
+
/**
|
|
45
|
+
* Asks a person. Resolves with their answer. The host's UI, or the React binding's default
|
|
46
|
+
* dialog; drawn outside the frame, never inside it. `proceed` (optional to use) runs the effect
|
|
47
|
+
* within the "yes" click itself.
|
|
48
|
+
*/
|
|
49
|
+
export type Confirm = (request: ConfirmRequest, proceed: Proceed) => Promise<ConfirmAnswer>;
|
|
36
50
|
/** Why a call didn't run. */
|
|
37
51
|
export type Refusal =
|
|
38
52
|
/** The config doesn't declare it. */
|
|
@@ -45,6 +59,8 @@ export type Refusal =
|
|
|
45
59
|
| "no-confirm"
|
|
46
60
|
/** The person declined. */
|
|
47
61
|
| "declined"
|
|
62
|
+
/** The person took it elsewhere (edited it first): not sent, not counted as a decline. */
|
|
63
|
+
| "redirected"
|
|
48
64
|
/** The display was muted after too many declines (until it runs again). */
|
|
49
65
|
| "muted" | "rate-limited"
|
|
50
66
|
/** The host's handler threw or rejected. */
|
package/dist/compile/policy.d.ts
CHANGED
|
@@ -48,8 +48,8 @@ export declare function allowAttribute(_config: PortholeConfig): string;
|
|
|
48
48
|
export declare function embedderFrameSources(config: PortholeConfig): string[];
|
|
49
49
|
/**
|
|
50
50
|
* The CSP the embedding page sends (a header, or the host's own document's policy), for every kind
|
|
51
|
-
* of display it shows.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* of display it shows. Frames only: no `default-src`, so the host's own scripts, styles and
|
|
52
|
+
* fetches stay its policy's; and no `child-src`, which the host's workers would fall back to (every
|
|
53
|
+
* engine Porthole supports reads frames from `frame-src`, P038).
|
|
54
54
|
*/
|
|
55
55
|
export declare function embedderCsp(configs: readonly PortholeConfig[]): string;
|
package/dist/compile/policy.js
CHANGED
|
@@ -69,7 +69,8 @@ export function sandboxTokens(config) {
|
|
|
69
69
|
case "domain":
|
|
70
70
|
return ["allow-scripts", "allow-same-origin"];
|
|
71
71
|
case "external":
|
|
72
|
-
|
|
72
|
+
// The host may add a documented few (popups, modals, downloads), never top navigation.
|
|
73
|
+
return ["allow-scripts", "allow-same-origin", "allow-forms", ...(config.sandbox ?? [])];
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
/**
|
|
@@ -92,15 +93,11 @@ export function embedderFrameSources(config) {
|
|
|
92
93
|
}
|
|
93
94
|
/**
|
|
94
95
|
* The CSP the embedding page sends (a header, or the host's own document's policy), for every kind
|
|
95
|
-
* of display it shows.
|
|
96
|
-
*
|
|
97
|
-
*
|
|
96
|
+
* of display it shows. Frames only: no `default-src`, so the host's own scripts, styles and
|
|
97
|
+
* fetches stay its policy's; and no `child-src`, which the host's workers would fall back to (every
|
|
98
|
+
* engine Porthole supports reads frames from `frame-src`, P038).
|
|
98
99
|
*/
|
|
99
100
|
export function embedderCsp(configs) {
|
|
100
101
|
const sources = unique(configs.flatMap(embedderFrameSources));
|
|
101
|
-
|
|
102
|
-
return serializeCsp([
|
|
103
|
-
["frame-src", ...all],
|
|
104
|
-
["child-src", ...all],
|
|
105
|
-
]);
|
|
102
|
+
return serializeCsp([["frame-src", ...(sources.length === 0 ? ["'none'"] : sources)]]);
|
|
106
103
|
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -67,6 +67,13 @@ export interface ThemeConfig {
|
|
|
67
67
|
readonly variables: Readonly<Record<string, ThemeValue>>;
|
|
68
68
|
/** The window event the shell fires after the theme changed, for code that must redraw. */
|
|
69
69
|
readonly event: string;
|
|
70
|
+
/**
|
|
71
|
+
* Whether a whole document (an artifact page) takes the person's light or dark scheme:
|
|
72
|
+
* `declared` (default): only a page that declares it supports both
|
|
73
|
+
* (`<meta name="color-scheme" content="light dark">`); any other page shows as its author
|
|
74
|
+
* styled it, in light. `always`: every page. The variables are there either way.
|
|
75
|
+
*/
|
|
76
|
+
readonly documents?: "declared" | "always";
|
|
70
77
|
}
|
|
71
78
|
export interface Limits {
|
|
72
79
|
/** The tallest the frame grows before its content scrolls inside it. */
|
|
@@ -83,7 +90,19 @@ export interface Limits {
|
|
|
83
90
|
readonly maxPromptChars: number;
|
|
84
91
|
/** The most errors one run of a display reports. */
|
|
85
92
|
readonly maxErrorReports: number;
|
|
93
|
+
/**
|
|
94
|
+
* How long a display may take to load before its outcome is a timeout (ms). Optional; 15 s
|
|
95
|
+
* when absent.
|
|
96
|
+
*/
|
|
97
|
+
readonly loadTimeoutMs?: number;
|
|
86
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Sandbox tokens a url-mode frame may add to its baseline, by the host's choice (P037): a
|
|
101
|
+
* person's own running app (a dev server's preview) may need popups (an OAuth flow), modals and
|
|
102
|
+
* downloads. Never top navigation, and nothing for other modes.
|
|
103
|
+
*/
|
|
104
|
+
export declare const URL_SANDBOX_EXTRAS: readonly ["allow-popups", "allow-popups-to-escape-sandbox", "allow-modals", "allow-downloads"];
|
|
105
|
+
export type UrlSandboxExtra = (typeof URL_SANDBOX_EXTRAS)[number];
|
|
87
106
|
/** Where the host shows the content, for the manual's size rules. */
|
|
88
107
|
export interface LayoutConfig {
|
|
89
108
|
/** The narrowest the frame ever is (a small phone). */
|
|
@@ -128,4 +147,6 @@ export interface PortholeConfig {
|
|
|
128
147
|
*/
|
|
129
148
|
readonly contentOrigins: readonly string[];
|
|
130
149
|
readonly embedder: EmbedderConfig;
|
|
150
|
+
/** Url mode only: sandbox tokens added to its baseline (`URL_SANDBOX_EXTRAS`). */
|
|
151
|
+
readonly sandbox?: readonly UrlSandboxExtra[];
|
|
131
152
|
}
|
package/dist/config/schema.js
CHANGED
|
@@ -1 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox tokens a url-mode frame may add to its baseline, by the host's choice (P037): a
|
|
3
|
+
* person's own running app (a dev server's preview) may need popups (an OAuth flow), modals and
|
|
4
|
+
* downloads. Never top navigation, and nothing for other modes.
|
|
5
|
+
*/
|
|
6
|
+
export const URL_SANDBOX_EXTRAS = [
|
|
7
|
+
"allow-popups",
|
|
8
|
+
"allow-popups-to-escape-sandbox",
|
|
9
|
+
"allow-modals",
|
|
10
|
+
"allow-downloads",
|
|
11
|
+
];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type PortholeConfig } from "./schema.ts";
|
|
2
2
|
/**
|
|
3
3
|
* Checks a config before anything is derived from it. Every string that ends up in a CSP, an
|
|
4
4
|
* attribute or the shell is checked here, so a malformed origin can't inject a directive
|
|
@@ -16,5 +16,7 @@ export declare const BUILT_IN_TOPICS: readonly ["widget", "chart", "interactive"
|
|
|
16
16
|
* every subdomain) and an optional port, nothing else. Insecure schemes only for loopback names.
|
|
17
17
|
*/
|
|
18
18
|
export declare function isOriginSource(value: unknown): value is string;
|
|
19
|
+
/** Why `value` isn't an origin source Porthole accepts, in words to act on; null if it is. */
|
|
20
|
+
export declare function originSourceProblem(value: unknown): string | null;
|
|
19
21
|
/** Returns `input` as a config once every field checks out; throws a `ConfigError` otherwise. */
|
|
20
22
|
export declare function validateConfig(input: unknown): PortholeConfig;
|
package/dist/config/validate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CAPABILITY_NAMES, isCapabilityName } from "./capabilities.js";
|
|
2
|
+
import { URL_SANDBOX_EXTRAS } from "./schema.js";
|
|
2
3
|
/**
|
|
3
4
|
* Checks a config before anything is derived from it. Every string that ends up in a CSP, an
|
|
4
5
|
* attribute or the shell is checked here, so a malformed origin can't inject a directive
|
|
@@ -22,24 +23,29 @@ const HOST = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{
|
|
|
22
23
|
* every subdomain) and an optional port, nothing else. Insecure schemes only for loopback names.
|
|
23
24
|
*/
|
|
24
25
|
export function isOriginSource(value) {
|
|
26
|
+
return originSourceProblem(value) === null;
|
|
27
|
+
}
|
|
28
|
+
/** Why `value` isn't an origin source Porthole accepts, in words to act on; null if it is. */
|
|
29
|
+
export function originSourceProblem(value) {
|
|
30
|
+
const shape = "is not an origin (scheme://host[:port], lowercase, no path)";
|
|
25
31
|
if (typeof value !== "string" || value.length > 253 + 16) {
|
|
26
|
-
return
|
|
32
|
+
return `${JSON.stringify(value)} ${shape}`;
|
|
27
33
|
}
|
|
28
34
|
const match = /^(https|http|wss|ws):\/\/([^/:]+|\[::1\])(:(\d{1,5}))?$/u.exec(value);
|
|
29
35
|
if (match === null) {
|
|
30
|
-
return
|
|
36
|
+
return `${JSON.stringify(value)} ${shape}`;
|
|
31
37
|
}
|
|
32
38
|
const [, scheme, host = "", , port] = match;
|
|
33
39
|
if (port !== undefined && (Number(port) < 1 || Number(port) > 65_535)) {
|
|
34
|
-
return
|
|
40
|
+
return `${JSON.stringify(value)} has no valid port`;
|
|
35
41
|
}
|
|
36
42
|
if (host !== "[::1]" && !HOST.test(host)) {
|
|
37
|
-
return
|
|
43
|
+
return `${JSON.stringify(value)} ${shape}`;
|
|
38
44
|
}
|
|
39
45
|
if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
|
|
40
|
-
return
|
|
46
|
+
return `${JSON.stringify(value)} uses ${scheme} off loopback: ${scheme} is allowed only for localhost, *.localhost, 127.0.0.1 and [::1] (loopback development); use ${scheme === "http" ? "https" : "wss"}`;
|
|
41
47
|
}
|
|
42
|
-
return
|
|
48
|
+
return null;
|
|
43
49
|
}
|
|
44
50
|
const fail = (path, message) => {
|
|
45
51
|
throw new ConfigError(path, message);
|
|
@@ -64,8 +70,9 @@ function sources(value, path) {
|
|
|
64
70
|
return;
|
|
65
71
|
}
|
|
66
72
|
value.forEach((source, index) => {
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
const problem = originSourceProblem(source);
|
|
74
|
+
if (problem !== null) {
|
|
75
|
+
fail(`${path}[${index}]`, problem);
|
|
69
76
|
}
|
|
70
77
|
});
|
|
71
78
|
}
|
|
@@ -102,6 +109,7 @@ export function validateConfig(input) {
|
|
|
102
109
|
"guide",
|
|
103
110
|
"contentOrigins",
|
|
104
111
|
"embedder",
|
|
112
|
+
"sandbox",
|
|
105
113
|
], "config");
|
|
106
114
|
if (!MODES.includes(config.mode)) {
|
|
107
115
|
fail("config.mode", `must be one of ${MODES.join(", ")}`);
|
|
@@ -142,7 +150,12 @@ export function validateConfig(input) {
|
|
|
142
150
|
sources(egress[kind], `config.egress.${kind}`);
|
|
143
151
|
}
|
|
144
152
|
const theme = object(config.theme, "config.theme");
|
|
145
|
-
only(theme, ["variables", "event"], "config.theme");
|
|
153
|
+
only(theme, ["variables", "event", "documents"], "config.theme");
|
|
154
|
+
if (theme.documents !== undefined &&
|
|
155
|
+
theme.documents !== "declared" &&
|
|
156
|
+
theme.documents !== "always") {
|
|
157
|
+
fail("config.theme.documents", 'must be "declared" or "always"');
|
|
158
|
+
}
|
|
146
159
|
const variables = object(theme.variables, "config.theme.variables");
|
|
147
160
|
for (const [name, value] of Object.entries(variables)) {
|
|
148
161
|
const path = `config.theme.variables[${JSON.stringify(name)}]`;
|
|
@@ -175,10 +188,13 @@ export function validateConfig(input) {
|
|
|
175
188
|
"maxPromptChars",
|
|
176
189
|
"maxErrorReports",
|
|
177
190
|
];
|
|
178
|
-
only(limits, limitNames, "config.limits");
|
|
191
|
+
only(limits, [...limitNames, "loadTimeoutMs"], "config.limits");
|
|
179
192
|
for (const name of limitNames) {
|
|
180
193
|
positive(limits[name], `config.limits.${name}`);
|
|
181
194
|
}
|
|
195
|
+
if (limits.loadTimeoutMs !== undefined) {
|
|
196
|
+
positive(limits.loadTimeoutMs, "config.limits.loadTimeoutMs", 600_000);
|
|
197
|
+
}
|
|
182
198
|
if (limits.minHeightPx > limits.maxHeightPx) {
|
|
183
199
|
fail("config.limits.minHeightPx", "must not exceed maxHeightPx");
|
|
184
200
|
}
|
|
@@ -226,5 +242,20 @@ export function validateConfig(input) {
|
|
|
226
242
|
sources(Array.isArray(embedder.frameSrc)
|
|
227
243
|
? embedder.frameSrc.filter((source) => source !== "'self'")
|
|
228
244
|
: embedder.frameSrc, "config.embedder.frameSrc");
|
|
245
|
+
if (config.sandbox !== undefined) {
|
|
246
|
+
if (config.mode !== "url") {
|
|
247
|
+
fail("config.sandbox", "only url mode may add sandbox tokens");
|
|
248
|
+
}
|
|
249
|
+
if (!Array.isArray(config.sandbox)) {
|
|
250
|
+
fail("config.sandbox", "must be an array of sandbox tokens");
|
|
251
|
+
}
|
|
252
|
+
config.sandbox.forEach((token, index) => {
|
|
253
|
+
if (!URL_SANDBOX_EXTRAS.includes(token)) {
|
|
254
|
+
fail(`config.sandbox[${index}]`, /top-navigation|same-origin/u.test(String(token))
|
|
255
|
+
? `${JSON.stringify(token)} is never granted (P037)`
|
|
256
|
+
: `must be one of ${URL_SANDBOX_EXTRAS.join(", ")}`);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
229
260
|
return input;
|
|
230
261
|
}
|
|
@@ -248,6 +248,56 @@ export const BEHAVIOR_PROBES = [
|
|
|
248
248
|
expect(styles.height, "a control is tall enough to tap").toBeGreaterThanOrEqual(44);
|
|
249
249
|
},
|
|
250
250
|
},
|
|
251
|
+
{
|
|
252
|
+
id: "11.document-theme",
|
|
253
|
+
item: 11,
|
|
254
|
+
title: "a whole page takes the person's scheme only when it declares it supports it (or the host says always)",
|
|
255
|
+
async run(context) {
|
|
256
|
+
const page = (head, id) => `<!doctype html><html><head><title>Page</title>${head}</head><body><p id="${id}">page</p></body></html>`;
|
|
257
|
+
const scheme = (display, id) => display.frame().then(async (frame) => {
|
|
258
|
+
await expect(frame.locator(`#${id}`)).toHaveText("page");
|
|
259
|
+
return frame.evaluate(() => ({
|
|
260
|
+
scheme: getComputedStyle(document.documentElement).colorScheme,
|
|
261
|
+
theme: document.documentElement.getAttribute("data-theme"),
|
|
262
|
+
variable: getComputedStyle(document.documentElement)
|
|
263
|
+
.getPropertyValue("--color-text-primary")
|
|
264
|
+
.trim() !== "",
|
|
265
|
+
}));
|
|
266
|
+
});
|
|
267
|
+
const plain = await context.launch({
|
|
268
|
+
config: presets.artifact(),
|
|
269
|
+
scheme: "dark",
|
|
270
|
+
content: page("", "plain"),
|
|
271
|
+
});
|
|
272
|
+
// An author's own page, declaring nothing: shown as styled, in light; the variables are there.
|
|
273
|
+
expect(await scheme(plain, "plain")).toEqual({
|
|
274
|
+
scheme: "normal",
|
|
275
|
+
theme: null,
|
|
276
|
+
variable: true,
|
|
277
|
+
});
|
|
278
|
+
await plain.setTheme("light");
|
|
279
|
+
await plain.setTheme("dark");
|
|
280
|
+
await settle(300);
|
|
281
|
+
expect(await scheme(plain, "plain")).toEqual({
|
|
282
|
+
scheme: "normal",
|
|
283
|
+
theme: null,
|
|
284
|
+
variable: true,
|
|
285
|
+
});
|
|
286
|
+
const declared = await context.launch({
|
|
287
|
+
config: presets.artifact(),
|
|
288
|
+
scheme: "dark",
|
|
289
|
+
content: page('<meta name="color-scheme" content="light dark">', "declared"),
|
|
290
|
+
});
|
|
291
|
+
await expect.poll(async () => (await scheme(declared, "declared")).theme).toBe("dark");
|
|
292
|
+
expect((await scheme(declared, "declared")).scheme).toBe("dark");
|
|
293
|
+
const always = await context.launch({
|
|
294
|
+
config: presets.artifact({ theme: { documents: "always" } }),
|
|
295
|
+
scheme: "dark",
|
|
296
|
+
content: page("", "always"),
|
|
297
|
+
});
|
|
298
|
+
await expect.poll(async () => (await scheme(always, "always")).theme).toBe("dark");
|
|
299
|
+
},
|
|
300
|
+
},
|
|
251
301
|
// ── 12: the host-function gates ────────────────────────────────────────────────────────────
|
|
252
302
|
{
|
|
253
303
|
id: "12.prompt-gesture-confirm",
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@botiverse/porthole/conformance`: the leak/conformance suite (P008), runnable by any host
|
|
3
3
|
* against its own integration. A host implements `ConformanceHost` (how to show one display in a
|
|
4
|
-
* page and drive it), then registers the
|
|
4
|
+
* page and drive it), then registers the tests in a spec file of its own, so they are located
|
|
5
|
+
* there (`playwright test conformance.spec.ts`, `--grep "\[1\.connect\]"`):
|
|
5
6
|
*
|
|
6
7
|
* import { test } from "@playwright/test";
|
|
7
|
-
* import {
|
|
8
|
-
*
|
|
8
|
+
* import { leakTests } from "@botiverse/porthole/conformance";
|
|
9
|
+
* for (const each of leakTests(myHost)) {
|
|
10
|
+
* test(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* The host's own `@playwright/test` (and, for the App SDK probes, `@modelcontextprotocol/ext-apps`)
|
|
14
|
+
* is what runs it: Porthole declares neither (P041).
|
|
9
15
|
*
|
|
10
16
|
* Each probe attempts one egress or escape channel (DESIGN.md § Egress hardening) and asserts,
|
|
11
17
|
* with an out-of-band oracle where one exists (an HTTP canary that also counts connections, a UDP
|
|
@@ -90,6 +96,6 @@ export interface LeakSuiteOptions {
|
|
|
90
96
|
/** Probe ids to skip, each with the reason (shown in the report). */
|
|
91
97
|
readonly skip?: Readonly<Record<string, string>>;
|
|
92
98
|
}
|
|
93
|
-
export { defineLeakSuite, PROBES, type Probe } from "./probes.ts";
|
|
99
|
+
export { defineLeakSuite, leakTests, PROBES, type ConformanceFixtures, type ConformanceTest, type Probe, } from "./probes.ts";
|
|
94
100
|
export { httpOracle, settle, udpOracle, type HttpOracle, type UdpOracle } from "./oracles.ts";
|
|
95
|
-
export { defineServedSuite, type ServedConformanceHost, type ServedDisplay, type ServedSuiteOptions, } from "./served.ts";
|
|
101
|
+
export { defineServedSuite, servedTests, type ServedConformanceHost, type ServedDisplay, type ServedSuiteOptions, } from "./served.ts";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { defineLeakSuite, PROBES } from "./probes.js";
|
|
1
|
+
export { defineLeakSuite, leakTests, PROBES, } from "./probes.js";
|
|
2
2
|
export { httpOracle, settle, udpOracle } from "./oracles.js";
|
|
3
3
|
// The served modes' cases (domain and url mode), with their own host interface.
|
|
4
|
-
export { defineServedSuite, } from "./served.js";
|
|
4
|
+
export { defineServedSuite, servedTests, } from "./served.js";
|
|
@@ -1,9 +1,33 @@
|
|
|
1
|
-
import { type PlaywrightTestArgs, type PlaywrightTestOptions, type PlaywrightWorkerArgs, type PlaywrightWorkerOptions, type TestType } from "@playwright/test";
|
|
1
|
+
import { type PlaywrightTestArgs, type PlaywrightTestOptions, type PlaywrightWorkerArgs, type PlaywrightWorkerOptions, type Page, type TestInfo, type TestType } from "@playwright/test";
|
|
2
2
|
import type { ConformanceHost, LeakSuiteOptions } from "./index.ts";
|
|
3
3
|
import { type Probe } from "./kit.ts";
|
|
4
4
|
export type { Probe, ProbeContext } from "./kit.ts";
|
|
5
5
|
export { attack } from "./kit.ts";
|
|
6
6
|
export declare const PROBES: readonly Probe[];
|
|
7
7
|
type AnyTest = TestType<PlaywrightTestArgs & PlaywrightTestOptions, PlaywrightWorkerArgs & PlaywrightWorkerOptions>;
|
|
8
|
-
/**
|
|
8
|
+
/** The fixtures a conformance test uses. */
|
|
9
|
+
export interface ConformanceFixtures {
|
|
10
|
+
readonly page: Page;
|
|
11
|
+
readonly browserName: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* One conformance test, for a host's own spec file to register: its tests are then located in that
|
|
15
|
+
* file, so `playwright test my-conformance.spec.ts` (or `--grep "\[1\.connect\]"`) finds them.
|
|
16
|
+
*
|
|
17
|
+
* for (const each of leakTests(myHost)) {
|
|
18
|
+
* test(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
export interface ConformanceTest {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
/** `[<id>] <what it proves>`: the probe id is greppable. */
|
|
24
|
+
readonly title: string;
|
|
25
|
+
run(fixtures: ConformanceFixtures, info: TestInfo): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/** Every probe as a test entry (see `ConformanceTest`). */
|
|
28
|
+
export declare function leakTests(host: ConformanceHost, options?: LeakSuiteOptions): ConformanceTest[];
|
|
29
|
+
/**
|
|
30
|
+
* Registers every probe as a test. Its tests are located in Porthole's own files; a host that
|
|
31
|
+
* wants to select them by its spec file registers `leakTests` itself instead.
|
|
32
|
+
*/
|
|
9
33
|
export declare function defineLeakSuite(test: AnyTest, host: ConformanceHost, options?: LeakSuiteOptions): void;
|
|
@@ -818,48 +818,59 @@ export const PROBES = [
|
|
|
818
818
|
},
|
|
819
819
|
...BEHAVIOR_PROBES,
|
|
820
820
|
];
|
|
821
|
-
/**
|
|
821
|
+
/** Every probe as a test entry (see `ConformanceTest`). */
|
|
822
|
+
export function leakTests(host, options = {}) {
|
|
823
|
+
return PROBES.map((probe) => ({
|
|
824
|
+
id: probe.id,
|
|
825
|
+
title: `[${probe.id}] ${probe.title}`,
|
|
826
|
+
async run({ page, browserName }, info) {
|
|
827
|
+
const reason = options.skip?.[probe.id];
|
|
828
|
+
info.skip(reason !== undefined, reason);
|
|
829
|
+
const closers = [];
|
|
830
|
+
const requests = [];
|
|
831
|
+
page.on("response", (response) => requests.push(response.url()));
|
|
832
|
+
try {
|
|
833
|
+
await probe.run({
|
|
834
|
+
page,
|
|
835
|
+
engine: browserName,
|
|
836
|
+
launch: (spec) => host.launch(page, spec),
|
|
837
|
+
http: async (files) => {
|
|
838
|
+
const oracle = await httpOracle(files);
|
|
839
|
+
closers.push(() => oracle.close());
|
|
840
|
+
return oracle;
|
|
841
|
+
},
|
|
842
|
+
udp: async () => {
|
|
843
|
+
const oracle = await udpOracle();
|
|
844
|
+
closers.push(() => oracle.close());
|
|
845
|
+
return oracle;
|
|
846
|
+
},
|
|
847
|
+
result: async (display, timeoutMs = 10_000) => {
|
|
848
|
+
await expect
|
|
849
|
+
.poll(async () => (await display.frame())
|
|
850
|
+
.evaluate(() => Boolean(window.portholeProbe
|
|
851
|
+
?.done))
|
|
852
|
+
.catch(() => false), { timeout: timeoutMs })
|
|
853
|
+
.toBe(true);
|
|
854
|
+
return (await display.frame()).evaluate(() => window.portholeProbe);
|
|
855
|
+
},
|
|
856
|
+
requestsTo: (origin) => requests.filter((url) => url.startsWith(`${origin}/`) || url === origin),
|
|
857
|
+
measured: (text) => info.annotations.push({ type: "measured", description: text }),
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
finally {
|
|
861
|
+
await Promise.all(closers.map((close) => close()));
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
}));
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Registers every probe as a test. Its tests are located in Porthole's own files; a host that
|
|
868
|
+
* wants to select them by its spec file registers `leakTests` itself instead.
|
|
869
|
+
*/
|
|
822
870
|
export function defineLeakSuite(test, host, options = {}) {
|
|
823
871
|
test.describe(`Porthole leak suite (${host.name})`, () => {
|
|
824
|
-
for (const
|
|
825
|
-
test(
|
|
826
|
-
const reason = options.skip?.[probe.id];
|
|
827
|
-
test.skip(reason !== undefined, reason);
|
|
828
|
-
const closers = [];
|
|
829
|
-
const requests = [];
|
|
830
|
-
page.on("response", (response) => requests.push(response.url()));
|
|
831
|
-
try {
|
|
832
|
-
await probe.run({
|
|
833
|
-
page,
|
|
834
|
-
engine: browserName,
|
|
835
|
-
launch: (spec) => host.launch(page, spec),
|
|
836
|
-
http: async (files) => {
|
|
837
|
-
const oracle = await httpOracle(files);
|
|
838
|
-
closers.push(() => oracle.close());
|
|
839
|
-
return oracle;
|
|
840
|
-
},
|
|
841
|
-
udp: async () => {
|
|
842
|
-
const oracle = await udpOracle();
|
|
843
|
-
closers.push(() => oracle.close());
|
|
844
|
-
return oracle;
|
|
845
|
-
},
|
|
846
|
-
result: async (display, timeoutMs = 10_000) => {
|
|
847
|
-
await expect
|
|
848
|
-
.poll(async () => (await display.frame())
|
|
849
|
-
.evaluate(() => Boolean(window
|
|
850
|
-
.portholeProbe?.done))
|
|
851
|
-
.catch(() => false), { timeout: timeoutMs })
|
|
852
|
-
.toBe(true);
|
|
853
|
-
return (await display.frame()).evaluate(() => window.portholeProbe);
|
|
854
|
-
},
|
|
855
|
-
requestsTo: (origin) => requests.filter((url) => url.startsWith(`${origin}/`) || url === origin),
|
|
856
|
-
measured: (text) => test.info().annotations.push({ type: "measured", description: text }),
|
|
857
|
-
});
|
|
858
|
-
}
|
|
859
|
-
finally {
|
|
860
|
-
await Promise.all(closers.map((close) => close()));
|
|
861
|
-
}
|
|
862
|
-
});
|
|
872
|
+
for (const each of leakTests(host, options)) {
|
|
873
|
+
test(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
|
|
863
874
|
}
|
|
864
875
|
});
|
|
865
876
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* one exists (a canary URL the host counts requests to), that nothing got through.
|
|
13
13
|
*/
|
|
14
14
|
import { type Frame, type Page, type TestType } from "@playwright/test";
|
|
15
|
+
import type { ConformanceTest } from "./probes.ts";
|
|
15
16
|
import type { EgressConfig } from "../config/schema.ts";
|
|
16
17
|
import type { FrameLocation, InstanceRef } from "../isolation/types.ts";
|
|
17
18
|
/** One served display, as the host shows it. */
|
|
@@ -65,4 +66,10 @@ export interface ServedSuiteOptions {
|
|
|
65
66
|
readonly skip?: Readonly<Record<string, string>>;
|
|
66
67
|
}
|
|
67
68
|
/** Registers every served-mode case as a test. */
|
|
69
|
+
/**
|
|
70
|
+
* Registers every served-mode case as a test. A host that wants to select them by its own spec
|
|
71
|
+
* file registers `servedTests` itself (see `ConformanceTest`).
|
|
72
|
+
*/
|
|
68
73
|
export declare function defineServedSuite(test: TestType<object, object>, host: ServedConformanceHost, options?: ServedSuiteOptions): void;
|
|
74
|
+
/** Every served-mode case as a test entry (see `ConformanceTest`). */
|
|
75
|
+
export declare function servedTests(host: ServedConformanceHost, options?: ServedSuiteOptions): ConformanceTest[];
|
|
@@ -71,17 +71,40 @@ async function plantHostSecrets(page) {
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
/** Registers every served-mode case as a test. */
|
|
74
|
+
/**
|
|
75
|
+
* Registers every served-mode case as a test. A host that wants to select them by its own spec
|
|
76
|
+
* file registers `servedTests` itself (see `ConformanceTest`).
|
|
77
|
+
*/
|
|
74
78
|
export function defineServedSuite(test, host, options = {}) {
|
|
75
79
|
const typed = test;
|
|
80
|
+
typed.describe(`served modes (${host.name})`, () => {
|
|
81
|
+
for (const each of servedTests(host, options)) {
|
|
82
|
+
// Playwright reads the fixtures a test uses from its parameter's destructuring.
|
|
83
|
+
typed(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** Every served-mode case as a test entry (see `ConformanceTest`). */
|
|
88
|
+
export function servedTests(host, options = {}) {
|
|
89
|
+
const tests = [];
|
|
90
|
+
let info = null;
|
|
91
|
+
// The case being run's own TestInfo (for an expected failure measured on one engine).
|
|
92
|
+
const typed = {
|
|
93
|
+
fail: (condition, reason) => info?.fail(condition, reason),
|
|
94
|
+
};
|
|
76
95
|
const define = (id, title, body) => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
96
|
+
tests.push({
|
|
97
|
+
id,
|
|
98
|
+
title: `${id}: ${title}`,
|
|
99
|
+
async run({ page, browserName }, testInfo) {
|
|
100
|
+
const reason = options.skip?.[id];
|
|
101
|
+
testInfo.skip(reason !== undefined, reason);
|
|
102
|
+
info = testInfo;
|
|
103
|
+
await body({ page, browserName });
|
|
104
|
+
},
|
|
82
105
|
});
|
|
83
106
|
};
|
|
84
|
-
|
|
107
|
+
{
|
|
85
108
|
const modes = [
|
|
86
109
|
["domain", (scope) => host.shell(scope)],
|
|
87
110
|
["url", (scope) => host.app(scope)],
|
|
@@ -307,5 +330,6 @@ export function defineServedSuite(test, host, options = {}) {
|
|
|
307
330
|
})
|
|
308
331
|
.toBe("ready");
|
|
309
332
|
});
|
|
310
|
-
}
|
|
333
|
+
}
|
|
334
|
+
return tests;
|
|
311
335
|
}
|