@botiverse/porthole 0.1.6 → 0.1.7

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.
@@ -80,9 +80,11 @@ export interface ThemeConfig {
80
80
  * Whether a whole document (an artifact page) takes the person's light or dark scheme:
81
81
  * `declared` (default): only a page that declares it supports both
82
82
  * (`<meta name="color-scheme" content="light dark">`); any other page shows as its author
83
- * styled it, in light. `always`: every page. The variables are there either way.
83
+ * styled it, in light. `always`: every page. `never` (pages only, P065): the host has no
84
+ * theme for it: it always gets the light values, its frame says light, and no theme change
85
+ * reaches it. The variables are there in every case.
84
86
  */
85
- readonly documents?: "declared" | "always";
87
+ readonly documents?: "declared" | "always" | "never";
86
88
  }
87
89
  export interface Limits {
88
90
  /** The tallest the frame grows before its content scrolls inside it. */
@@ -126,6 +128,13 @@ export interface LayoutConfig {
126
128
  readonly narrowWidthPx: number;
127
129
  /** The widest the frame is (a desktop column). */
128
130
  readonly wideWidthPx: number;
131
+ /**
132
+ * How the frame sits in the host (P065). `flow` (default): a card in a column, as tall as its
133
+ * content (up to `maxHeightPx`). `fill`: it fills the viewer (the page itself): as tall as its
134
+ * container, the content scrolling itself, `position: fixed` and `sticky` working. Every mount
135
+ * then fills (`MountOptions.fill`), and the manual says so.
136
+ */
137
+ readonly kind?: "flow" | "fill";
129
138
  }
130
139
  export interface GuideConfig {
131
140
  /** The host's tool that shows this content, as the manual names it (`show_widget`). */
@@ -173,8 +182,22 @@ export interface PortholeConfig {
173
182
  * "conversation" }`), so the manual says storage is shared across it and keys need a prefix.
174
183
  */
175
184
  readonly storage?: StorageConfig;
185
+ /**
186
+ * Where the content's own errors go, for the manual (P065): `agent`, back to the agent that
187
+ * wrote it; `host`, to the app only (the agent never sees them); `none`, nowhere (the config
188
+ * declares no `reportError`). Absent: the manual says they are reported to the app.
189
+ */
190
+ readonly errors?: "agent" | "host" | "none";
176
191
  }
177
192
  export interface StorageConfig {
178
- /** What the displays sharing one origin have in common, as a noun ("conversation"). */
193
+ /**
194
+ * What the displays sharing one origin at once have in common, as a noun ("conversation"):
195
+ * the manual tells authors to prefix their keys.
196
+ */
179
197
  readonly shared?: string;
198
+ /**
199
+ * What one display's storage is kept for, when only one display uses its origin at a time, in
200
+ * words ("this artifact, across its versions", P065): the manual says so, with no prefix advice.
201
+ */
202
+ readonly scope?: string;
180
203
  }
@@ -259,6 +259,7 @@ export function validateConfig(input) {
259
259
  "sandbox",
260
260
  "permissions",
261
261
  "storage",
262
+ "errors",
262
263
  ], "config");
263
264
  if (!MODES.includes(config.mode)) {
264
265
  fail("config.mode", `must be one of ${MODES.join(", ")}`);
@@ -315,8 +316,12 @@ export function validateConfig(input) {
315
316
  only(theme, ["variables", "event", "documents"], "config.theme");
316
317
  if (theme.documents !== undefined &&
317
318
  theme.documents !== "declared" &&
318
- theme.documents !== "always") {
319
- fail("config.theme.documents", 'must be "declared" or "always"');
319
+ theme.documents !== "always" &&
320
+ theme.documents !== "never") {
321
+ fail("config.theme.documents", 'must be "declared", "always" or "never"');
322
+ }
323
+ if (theme.documents === "never" && config.mode !== "artifact") {
324
+ fail("config.theme.documents", '"never" is for whole pages: a fragment takes the theme it is drawn in');
320
325
  }
321
326
  const variables = object(theme.variables, "config.theme.variables");
322
327
  for (const [name, value] of Object.entries(variables)) {
@@ -361,7 +366,10 @@ export function validateConfig(input) {
361
366
  fail("config.limits.minHeightPx", "must not exceed maxHeightPx");
362
367
  }
363
368
  const layout = object(config.layout, "config.layout");
364
- only(layout, ["minWidthPx", "narrowWidthPx", "wideWidthPx"], "config.layout");
369
+ only(layout, ["minWidthPx", "narrowWidthPx", "wideWidthPx", "kind"], "config.layout");
370
+ if (layout.kind !== undefined && layout.kind !== "flow" && layout.kind !== "fill") {
371
+ fail("config.layout.kind", 'must be "flow" or "fill"');
372
+ }
365
373
  for (const name of ["minWidthPx", "narrowWidthPx", "wideWidthPx"]) {
366
374
  positive(layout[name], `config.layout.${name}`, 10_000);
367
375
  }
@@ -421,7 +429,14 @@ export function validateConfig(input) {
421
429
  }
422
430
  if (config.storage !== undefined) {
423
431
  const storage = object(config.storage, "config.storage");
424
- only(storage, ["shared"], "config.storage");
432
+ only(storage, ["shared", "scope"], "config.storage");
433
+ if (storage.shared !== undefined && storage.scope !== undefined) {
434
+ fail("config.storage", "shared (several displays at once) or scope (one at a time), not both");
435
+ }
436
+ if (storage.scope !== undefined &&
437
+ (typeof storage.scope !== "string" || !/^[a-z][a-z ,'-]{0,59}$/u.test(storage.scope))) {
438
+ fail("config.storage.scope", 'must be short lowercase words ("this artifact, across its versions")');
439
+ }
425
440
  if (config.isolation !== "domain") {
426
441
  fail("config.storage", "only domain isolation keeps storage across displays");
427
442
  }
@@ -430,6 +445,16 @@ export function validateConfig(input) {
430
445
  fail("config.storage.shared", 'must be a short lowercase noun ("conversation")');
431
446
  }
432
447
  }
448
+ if (config.errors !== undefined) {
449
+ if (config.errors !== "agent" && config.errors !== "host" && config.errors !== "none") {
450
+ fail("config.errors", 'must be "agent", "host" or "none"');
451
+ }
452
+ if ((config.errors === "none") !== (capabilities.reportError === undefined)) {
453
+ fail("config.errors", config.errors === "none"
454
+ ? '"none" means no reportError: drop the capability'
455
+ : `${JSON.stringify(config.errors)} needs the reportError capability, which carries them`);
456
+ }
457
+ }
433
458
  if (config.permissions !== undefined) {
434
459
  if (config.mode !== "url") {
435
460
  fail("config.permissions", "only url mode may be delegated features");
@@ -39,6 +39,8 @@ export interface LaunchSpec {
39
39
  readonly scheme?: ColorScheme;
40
40
  /** The frame's width, px. */
41
41
  readonly widthPx?: number;
42
+ /** The height of the box the frame sits in, px: for a layout that fills it (`layout.kind`). */
43
+ readonly heightPx?: number;
42
44
  /** The host's live values for theme variables, per scheme (as `HostOptions.theme` answers). */
43
45
  readonly hostTheme?: {
44
46
  readonly light?: Readonly<Record<string, string>>;
@@ -31,7 +31,8 @@ export interface Probe {
31
31
  readonly id: string;
32
32
  /**
33
33
  * The DESIGN.md egress-table item it proves; 0 for the shell's own wiring and interop, 11 for
34
- * behavior (streaming, sizing, theming), 12 for host-function gates, 13 for the clipboard.
34
+ * behavior (streaming, sizing, theming), 12 for host-function gates, 13 for the clipboard, 14
35
+ * for the manual's claims about the sandbox (`LIMIT_CHECKS`).
35
36
  */
36
37
  readonly item: number;
37
38
  readonly title: string;
@@ -0,0 +1,10 @@
1
+ import type { LimitId } from "../guide/limits.ts";
2
+ import { type Probe } from "./kit.ts";
3
+ export declare const LIMIT_PROBES: readonly Probe[];
4
+ /**
5
+ * The checks that prove each limit the manual can state: leak-suite probes, and served cases for
6
+ * what holds in domain mode. A unit test fails if a limit names none, or one that doesn't exist.
7
+ */
8
+ export declare const LIMIT_CHECKS: {
9
+ readonly [Id in LimitId]: readonly string[];
10
+ };
@@ -0,0 +1,508 @@
1
+ import { expect } from "@playwright/test";
2
+ import { presets } from "../presets/presets.js";
3
+ import { resolveTheme } from "../theme/variables.js";
4
+ import { attack, inline, js } from "./kit.js";
5
+ import { settle } from "./oracles.js";
6
+ /**
7
+ * The manual's claims about the sandbox, each proven in a real browser (item 14, P064). Every
8
+ * limit the manual can state (`manualLimits`) names the checks that prove it here (`LIMIT_CHECKS`);
9
+ * a unit test fails if it names none, so a new line can't go untested. Where engines differ, the
10
+ * check holds the safe common behavior the manual states and records what each engine did.
11
+ */
12
+ /** A real click in the display (the user activation a person's click gives). */
13
+ async function click(display, selector) {
14
+ await (await display.frame()).locator(selector).click();
15
+ }
16
+ /** What the content recorded in `window.r`, once `done` is set. */
17
+ async function recorded(display, timeoutMs = 10_000) {
18
+ const frame = await display.frame();
19
+ await expect
20
+ .poll(() => frame
21
+ .evaluate(() => window.r?.done === true)
22
+ .catch(() => false), { timeout: timeoutMs })
23
+ .toBe(true);
24
+ return frame.evaluate(() => window.r);
25
+ }
26
+ /** Counts what the page shows the person beyond the frame: dialogs, downloads, new windows. */
27
+ function watch(page) {
28
+ const seen = { dialogs: [], downloads: [], windows: [] };
29
+ page.on("dialog", (dialog) => {
30
+ seen.dialogs.push(dialog.type());
31
+ void dialog.dismiss().catch(() => { });
32
+ });
33
+ page.on("download", (download) => seen.downloads.push(download.suggestedFilename()));
34
+ page.context().on("page", (opened) => seen.windows.push(opened.url()));
35
+ return seen;
36
+ }
37
+ /** A WAV of silence, small enough to inline. */
38
+ const SILENCE = "data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YSADAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgA==";
39
+ const RECORDER = "<script>window.r = {};</script>";
40
+ export const LIMIT_PROBES = [
41
+ {
42
+ id: "14.fill",
43
+ item: 14,
44
+ title: "a layout that fills: the frame is its box's height, the page scrolls itself, position: fixed stays put",
45
+ async run(context) {
46
+ const display = await context.launch({
47
+ config: presets.artifact({ layout: { kind: "fill" } }),
48
+ heightPx: 400,
49
+ content: `<!doctype html><html><head><title>Tall</title></head><body style="margin:0">
50
+ <header id="bar" style="position:fixed;top:0;left:0;right:0;height:30px">bar</header>
51
+ <div style="height:2000px">tall</div></body></html>`,
52
+ });
53
+ const frame = await display.frame();
54
+ await expect(frame.locator("#bar")).toBeAttached();
55
+ await expect.poll(async () => Math.round((await display.box()).height)).toBe(400);
56
+ await frame.evaluate(() => window.scrollTo(0, 600));
57
+ const seen = await frame.evaluate(() => ({
58
+ scrolled: window.scrollY,
59
+ scrolls: document.documentElement.scrollHeight > window.innerHeight,
60
+ barTop: Math.round(document.getElementById("bar").getBoundingClientRect().top),
61
+ }));
62
+ expect(seen).toEqual({ scrolled: 600, scrolls: true, barTop: 0 });
63
+ await settle(500);
64
+ expect(Math.round((await display.box()).height), "it doesn't grow with its content").toBe(400);
65
+ },
66
+ },
67
+ {
68
+ id: "14.theme-never",
69
+ item: 14,
70
+ title: "a page the host doesn't theme stays light, whatever the person's scheme, and hears no change",
71
+ async run(context) {
72
+ const config = presets.artifact({ theme: { documents: "never" } });
73
+ const light = resolveTheme(config.theme, "light");
74
+ const display = await context.launch({
75
+ config,
76
+ scheme: "dark",
77
+ content: `<!doctype html><html><head><meta name="color-scheme" content="light dark"><title>Light</title></head><body><p id="p">text</p>
78
+ <script>window.events = 0; addEventListener("porthole:theme", () => (window.events += 1));</script></body></html>`,
79
+ });
80
+ const frame = await display.frame();
81
+ await expect(frame.locator("#p")).toHaveText("text");
82
+ await display.setTheme("dark");
83
+ await settle(500);
84
+ const seen = await frame.evaluate(() => ({
85
+ theme: document.documentElement.dataset.theme ?? null,
86
+ text: getComputedStyle(document.documentElement)
87
+ .getPropertyValue("--color-text-primary")
88
+ .trim(),
89
+ events: window.events,
90
+ prefers: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
91
+ }));
92
+ expect(seen.theme).toBeNull();
93
+ expect(seen.text).toBe(light["--color-text-primary"]);
94
+ expect(seen.events).toBe(0);
95
+ const element = await frame.frameElement();
96
+ expect(await element.evaluate((node) => node.style.colorScheme)).toBe("light");
97
+ context.measured(`prefers-color-scheme inside, the person on dark: ${seen.prefers}`);
98
+ },
99
+ },
100
+ {
101
+ id: "14.errors",
102
+ item: 14,
103
+ title: "what content throws reaches the host where reportError is declared, and nothing where it isn't",
104
+ async run(context) {
105
+ const thrower = `<p>x</p><script>throw new Error("porthole-thrown")</script>`;
106
+ const reported = await context.launch({
107
+ config: inline({ errors: "host" }),
108
+ content: thrower,
109
+ });
110
+ await expect
111
+ .poll(async () => (await reported.errors()).some((each) => each.includes("porthole-thrown") || each.includes("Script error")))
112
+ .toBe(true);
113
+ const quiet = await context.launch({ config: inline({ errors: "none" }), content: thrower });
114
+ await expect((await quiet.frame()).locator("p")).toHaveText("x");
115
+ await settle(800);
116
+ expect(await quiet.errors()).toEqual([]);
117
+ },
118
+ },
119
+ {
120
+ id: "14.silent",
121
+ item: 14,
122
+ title: "what the sandbox blocks fails silently: nothing loads, a fetch rejects, nothing is shown",
123
+ async run(context) {
124
+ const canary = await context.http();
125
+ const seen = watch(context.page);
126
+ const c = canary.origin;
127
+ const display = await context.launch({
128
+ config: inline(),
129
+ content: attack(`
130
+ r.fetch = await fetch(${js(`${c}/fetch`)}).then(() => "sent", (e) => "rejected " + e.name);
131
+ const image = document.getElementById("img");
132
+ r.image = await new Promise((resolve) => { if (image.complete) resolve(image.naturalWidth > 0 ? "loaded" : "failed"); image.addEventListener("load", () => resolve("loaded")); image.addEventListener("error", () => resolve("failed")); setTimeout(() => resolve("pending"), 3000); });
133
+ r.script = typeof window.__portholeBlocked;
134
+ r.after = "ran";`, `<img id="img" src="${c}/img.png"><link rel="stylesheet" href="${c}/style.css"><script src="${c}/script.js"></script>`),
135
+ });
136
+ const verdict = await context.result(display, 15_000);
137
+ await settle();
138
+ expect(verdict).toMatchObject({
139
+ fetch: "rejected TypeError",
140
+ image: "failed",
141
+ script: "undefined",
142
+ after: "ran",
143
+ });
144
+ expect(canary.requests()).toEqual([]);
145
+ expect(seen.dialogs).toEqual([]);
146
+ },
147
+ },
148
+ {
149
+ id: "14.inline-assets",
150
+ item: 14,
151
+ title: "assets inline as data: URLs and CSS in a <style> work where nothing outside is open",
152
+ async run(context) {
153
+ const canary = await context.http();
154
+ const pixel = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
155
+ const display = await context.launch({
156
+ config: inline(),
157
+ content: attack(`
158
+ const image = document.getElementById("inline");
159
+ r.inline = await new Promise((resolve) => { if (image.complete && image.naturalWidth > 0) resolve("loaded"); image.addEventListener("load", () => resolve("loaded")); image.addEventListener("error", () => resolve("failed")); setTimeout(() => resolve("pending"), 3000); });
160
+ r.styled = getComputedStyle(document.getElementById("styled")).width;`, `<style>#styled { width: 37px }</style><div id="styled"></div><img id="inline" src="${pixel}"><img src="${canary.origin}/outside.png">`),
161
+ });
162
+ const verdict = await context.result(display);
163
+ await settle();
164
+ expect(verdict).toMatchObject({ inline: "loaded", styled: "37px" });
165
+ expect(canary.requests()).toEqual([]);
166
+ },
167
+ },
168
+ {
169
+ id: "14.dialogs",
170
+ item: 14,
171
+ title: "alert, confirm and prompt show nothing: confirm returns false, prompt null",
172
+ async run(context) {
173
+ const seen = watch(context.page);
174
+ const display = await context.launch({
175
+ config: inline(),
176
+ content: `<button id="go">Ask</button>${RECORDER}<script>
177
+ document.getElementById("go").addEventListener("click", () => {
178
+ const started = Date.now();
179
+ r.alert = String(alert("Hello"));
180
+ r.confirm = String(confirm("Sure?"));
181
+ r.prompt = String(prompt("Name?", "default"));
182
+ r.ms = Date.now() - started;
183
+ r.done = true;
184
+ });
185
+ </script>`,
186
+ });
187
+ await click(display, "#go");
188
+ const verdict = await recorded(display);
189
+ expect(verdict).toMatchObject({ alert: "undefined", confirm: "false", prompt: "null" });
190
+ expect(verdict.ms).toBeLessThan(1_000);
191
+ expect(seen.dialogs).toEqual([]);
192
+ },
193
+ },
194
+ {
195
+ id: "14.print",
196
+ item: 14,
197
+ title: "window.print() does nothing",
198
+ async run(context) {
199
+ const seen = watch(context.page);
200
+ const display = await context.launch({
201
+ config: inline(),
202
+ content: `<button id="go">Print</button>${RECORDER}<script>
203
+ addEventListener("beforeprint", () => (r.beforeprint = true));
204
+ addEventListener("afterprint", () => (r.afterprint = true));
205
+ document.getElementById("go").addEventListener("click", () => {
206
+ r.print = String(print());
207
+ setTimeout(() => (r.done = true), 800);
208
+ });
209
+ </script>`,
210
+ });
211
+ await click(display, "#go");
212
+ const verdict = await recorded(display);
213
+ expect(verdict.print).toBe("undefined");
214
+ expect(verdict.beforeprint).toBeUndefined();
215
+ expect(verdict.afterprint).toBeUndefined();
216
+ expect(seen.dialogs).toEqual([]);
217
+ },
218
+ },
219
+ {
220
+ id: "14.downloads",
221
+ item: 14,
222
+ title: "downloads don't work: <a download>, data: and blob: links, saves from script",
223
+ async run(context) {
224
+ const seen = watch(context.page);
225
+ const display = await context.launch({
226
+ config: inline(),
227
+ content: `<a id="data" download="a.txt" href="data:text/plain,hello">data</a>
228
+ <button id="blob">blob</button><button id="script">script</button>
229
+ <script>
230
+ const save = (name) => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob(["hi"], { type: "text/plain" })); a.download = name; document.body.append(a); a.click(); };
231
+ document.getElementById("blob").addEventListener("click", () => save("b.txt"));
232
+ document.getElementById("script").addEventListener("click", () => { try { location.href = URL.createObjectURL(new Blob(["x"], { type: "application/octet-stream" })); } catch (e) {} });
233
+ </script>`,
234
+ });
235
+ for (const selector of ["#data", "#blob", "#script"]) {
236
+ await click(display, selector);
237
+ }
238
+ await settle(1_000);
239
+ expect(seen.downloads).toEqual([]);
240
+ },
241
+ },
242
+ {
243
+ id: "14.forms",
244
+ item: 14,
245
+ title: "forms never submit anywhere; a button's click handler is how a form works",
246
+ async run(context) {
247
+ const canary = await context.http();
248
+ const display = await context.launch({
249
+ config: inline(),
250
+ content: `<form id="form" action="${canary.origin}/submit"><input name="q" value="secret"><button id="submit">Send</button><button id="button" type="button">Apply</button></form>${RECORDER}<script>
251
+ document.getElementById("form").addEventListener("submit", () => (r.submitEvent = (r.submitEvent || 0) + 1));
252
+ document.getElementById("button").addEventListener("click", () => (r.clicked = true));
253
+ </script>`,
254
+ });
255
+ await click(display, "#submit");
256
+ await click(display, "#button");
257
+ await settle(800);
258
+ const frame = await display.frame();
259
+ const state = await frame.evaluate(() => ({
260
+ r: window.r,
261
+ form: document.getElementById("form") !== null,
262
+ }));
263
+ expect(state.form, "the frame stayed on its content").toBe(true);
264
+ expect(state.r.clicked).toBe(true);
265
+ expect(canary.requests()).toEqual([]);
266
+ context.measured(`submit event fired: ${JSON.stringify(state.r.submitEvent ?? 0)} time(s)`);
267
+ },
268
+ },
269
+ {
270
+ id: "14.link-schemes",
271
+ item: 14,
272
+ title: "mailto:, tel: and sms: links open nothing",
273
+ async run(context) {
274
+ const seen = watch(context.page);
275
+ const display = await context.launch({
276
+ config: inline({ capabilities: { openLink: {} } }),
277
+ content: `<a id="mail" href="mailto:someone@example.com">mail</a> <a id="tel" href="tel:+15551234567">tel</a> <a id="sms" href="sms:+15551234567">sms</a><p id="still">still here</p>`,
278
+ });
279
+ for (const selector of ["#mail", "#tel", "#sms"]) {
280
+ await click(display, selector);
281
+ }
282
+ await settle(800);
283
+ await expect((await display.frame()).locator("#still")).toHaveText("still here");
284
+ expect(seen.windows).toEqual([]);
285
+ expect(await display.calls(), "nothing went to the host").toEqual([]);
286
+ },
287
+ },
288
+ {
289
+ id: "14.windows",
290
+ item: 14,
291
+ title: "window.open and target=_blank open no window; with openLink, a link goes through the app",
292
+ async run(context) {
293
+ const seen = watch(context.page);
294
+ const content = `<a id="blank" href="https://example.com/report" target="_blank">report</a><button id="open">open</button>${RECORDER}<script>
295
+ document.getElementById("open").addEventListener("click", () => {
296
+ try { r.open = window.open("https://example.com/") === null ? "null" : "window"; } catch (e) { r.open = "throws"; }
297
+ r.done = true;
298
+ });
299
+ </script>`;
300
+ const closed = await context.launch({ config: inline(), content });
301
+ await click(closed, "#open");
302
+ const verdict = await recorded(closed);
303
+ expect(["null", "throws"]).toContain(verdict.open);
304
+ context.measured(`window.open: ${String(verdict.open)}`);
305
+ await click(closed, "#blank");
306
+ await settle(800);
307
+ expect(seen.windows).toEqual([]);
308
+ // With openLink declared, the link asks the app instead (the person says no here).
309
+ const linked = await context.launch({
310
+ config: inline({ capabilities: { openLink: {} } }),
311
+ content,
312
+ });
313
+ await click(linked, "#blank");
314
+ const asked = await linked.answerNextConfirmation(false);
315
+ expect(asked).toMatchObject({
316
+ capability: "openLink",
317
+ args: { url: "https://example.com/report" },
318
+ });
319
+ await settle(500);
320
+ expect(seen.windows).toEqual([]);
321
+ },
322
+ },
323
+ {
324
+ id: "14.devices",
325
+ item: 14,
326
+ title: "camera, microphone, location and sharing are denied without asking; files can be read",
327
+ async run(context) {
328
+ const seen = watch(context.page);
329
+ const display = await context.launch({
330
+ config: inline(),
331
+ content: `<button id="go">Ask</button><input id="file" type="file">${RECORDER}<script>
332
+ const settle = (p) => Promise.race([p, new Promise((resolve) => setTimeout(() => resolve("waiting"), 4000))]);
333
+ document.getElementById("go").addEventListener("click", async () => {
334
+ r.media = navigator.mediaDevices && navigator.mediaDevices.getUserMedia ? await settle(navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(() => "granted", (e) => "rejected " + e.name)) : "absent";
335
+ r.location = navigator.geolocation ? await settle(new Promise((resolve) => navigator.geolocation.getCurrentPosition(() => resolve("granted"), (e) => resolve("denied " + e.code)))) : "absent";
336
+ r.share = typeof navigator.share === "function" ? await settle(navigator.share({ text: "x" }).then(() => "shared", (e) => "rejected " + e.name)) : "absent";
337
+ r.asked = true;
338
+ });
339
+ document.getElementById("file").addEventListener("change", (event) => {
340
+ const reader = new FileReader();
341
+ reader.addEventListener("load", () => { r.file = reader.result; r.done = r.asked === true; });
342
+ reader.readAsText(event.target.files[0]);
343
+ });
344
+ </script>`,
345
+ });
346
+ await click(display, "#go");
347
+ const frame = await display.frame();
348
+ await expect
349
+ .poll(() => frame.evaluate(() => window.r.asked === true), {
350
+ timeout: 15_000,
351
+ })
352
+ .toBe(true);
353
+ await frame.locator("#file").setInputFiles({
354
+ name: "notes.txt",
355
+ mimeType: "text/plain",
356
+ buffer: Buffer.from("file text"),
357
+ });
358
+ const verdict = await recorded(display);
359
+ expect(String(verdict.media)).toMatch(/^(rejected|absent)/u);
360
+ expect(String(verdict.location)).toMatch(/^(denied 1|absent)$/u);
361
+ expect(String(verdict.share)).toMatch(/^(rejected|absent)/u);
362
+ expect(verdict.file).toBe("file text");
363
+ expect(seen.dialogs).toEqual([]);
364
+ context.measured(`camera/microphone: ${String(verdict.media)}; share: ${String(verdict.share)}`);
365
+ },
366
+ },
367
+ {
368
+ id: "14.workers",
369
+ item: 14,
370
+ title: "Web Workers from blob: work; service workers can't be registered; WebRTC is gone",
371
+ async run(context) {
372
+ const display = await context.launch({
373
+ config: inline(),
374
+ content: attack(`
375
+ r.blobBlocked = false;
376
+ document.addEventListener("securitypolicyviolation", (event) => { if (String(event.blockedURI).indexOf("blob") === 0) r.blobBlocked = true; });
377
+ r.worker = await new Promise((resolve) => { try { const worker = new Worker(URL.createObjectURL(new Blob(["postMessage(6 * 7)"], { type: "text/javascript" }))); worker.addEventListener("message", (event) => resolve("answered " + event.data)); worker.addEventListener("error", () => resolve("error")); } catch (e) { resolve("throws " + e.name); } setTimeout(() => resolve("silent"), 3000); });
378
+ try {
379
+ r.serviceWorker = !navigator.serviceWorker ? "absent" : await navigator.serviceWorker.register("worker.js").then(() => "registered", (e) => "rejected " + e.name);
380
+ } catch (e) { r.serviceWorker = "throws " + e.name; }
381
+ r.rtc = typeof RTCPeerConnection;`),
382
+ });
383
+ const verdict = await context.result(display);
384
+ // The frame's own policy allows blob: workers: a violation for one is the page's (P058).
385
+ if (verdict.worker !== "answered 42" && verdict.blobBlocked === true) {
386
+ context.inapplicable("the page's own CSP, which an opaque frame inherits, blocks blob: workers (worker-src falls back to its script-src): opaque content here gets no workers, whatever its config");
387
+ }
388
+ expect(verdict.worker).toBe("answered 42");
389
+ expect(String(verdict.serviceWorker)).toMatch(/^(absent|rejected|throws)/u);
390
+ expect(verdict.rtc).toBe("undefined");
391
+ context.measured(`service worker: ${String(verdict.serviceWorker)}`);
392
+ },
393
+ },
394
+ {
395
+ id: "14.sound",
396
+ item: 14,
397
+ title: "sound plays from a click",
398
+ async run(context) {
399
+ const display = await context.launch({
400
+ config: inline(),
401
+ content: `<button id="go">Play</button>${RECORDER}<script>
402
+ new Audio(${js(SILENCE)}).play().then(() => (r.unasked = "played"), (e) => (r.unasked = "refused " + e.name));
403
+ document.getElementById("go").addEventListener("click", () => {
404
+ new Audio(${js(SILENCE)}).play().then(() => { r.clicked = "played"; r.done = true; }, (e) => { r.clicked = "refused " + e.name; r.done = true; });
405
+ });
406
+ </script>`,
407
+ });
408
+ await settle(500);
409
+ await click(display, "#go");
410
+ const verdict = await recorded(display);
411
+ expect(verdict.clicked, "play() in a click's handler").toBe("played");
412
+ context.measured(`play() without a click: ${String(verdict.unasked)}`);
413
+ },
414
+ },
415
+ {
416
+ id: "14.address",
417
+ item: 14,
418
+ title: "an opaque frame's address is about:srcdoc, with no query string or hash",
419
+ async run(context) {
420
+ const display = await context.launch({
421
+ config: inline(),
422
+ content: attack(`
423
+ r.href = location.href;
424
+ r.search = location.search;
425
+ r.hash = location.hash;`),
426
+ });
427
+ expect(await context.result(display)).toMatchObject({
428
+ href: "about:srcdoc",
429
+ search: "",
430
+ hash: "",
431
+ });
432
+ },
433
+ },
434
+ {
435
+ id: "14.shell",
436
+ item: 14,
437
+ title: "document.write and open throw; nested frames are removed; full screen and the clipboard are closed",
438
+ async run(context) {
439
+ const display = await context.launch({
440
+ config: inline(),
441
+ content: `<button id="go">Go</button>${RECORDER}<script>
442
+ document.getElementById("go").addEventListener("click", async () => {
443
+ try { document.write("<p>x</p>"); r.write = "wrote"; } catch (e) { r.write = "throws"; }
444
+ try { document.open(); r.open = "opened"; } catch (e) { r.open = "throws"; }
445
+ const frame = document.createElement("iframe");
446
+ document.body.append(frame);
447
+ await new Promise((resolve) => setTimeout(resolve, 100));
448
+ r.frames = document.querySelectorAll("iframe, object, embed").length;
449
+ r.fullscreen = await document.documentElement.requestFullscreen().then(() => "entered", (e) => "rejected");
450
+ r.clipboard = navigator.clipboard ? await navigator.clipboard.writeText("x").then(() => "wrote", () => "rejected") : "absent";
451
+ r.done = true;
452
+ });
453
+ </script>`,
454
+ });
455
+ await click(display, "#go");
456
+ expect(await recorded(display)).toMatchObject({
457
+ write: "throws",
458
+ open: "throws",
459
+ frames: 0,
460
+ fullscreen: "rejected",
461
+ });
462
+ expect(["rejected", "absent"]).toContain((await recorded(display)).clipboard);
463
+ },
464
+ },
465
+ {
466
+ id: "14.storage",
467
+ item: 14,
468
+ title: "storage in an opaque frame works while it is shown and is its own: no other frame reads it",
469
+ async run(context) {
470
+ const first = await context.launch({
471
+ config: inline(),
472
+ content: attack(`
473
+ localStorage.setItem("porthole-limits", "first");
474
+ r.read = localStorage.getItem("porthole-limits");`),
475
+ });
476
+ expect((await context.result(first)).read).toBe("first");
477
+ const second = await context.launch({
478
+ config: inline(),
479
+ content: attack(`r.read = String(localStorage.getItem("porthole-limits"));`),
480
+ });
481
+ expect((await context.result(second)).read, "another frame's storage").toBe("null");
482
+ },
483
+ },
484
+ ];
485
+ /**
486
+ * The checks that prove each limit the manual can state: leak-suite probes, and served cases for
487
+ * what holds in domain mode. A unit test fails if a limit names none, or one that doesn't exist.
488
+ */
489
+ export const LIMIT_CHECKS = {
490
+ layout: ["11.size", "14.fill"],
491
+ theme: ["11.theme-flip", "11.document-theme", "14.theme-never"],
492
+ errors: ["14.errors"],
493
+ silent: ["14.silent"],
494
+ network: ["1.connect", "2.resources"],
495
+ "inline-assets": ["14.inline-assets"],
496
+ dialogs: ["14.dialogs"],
497
+ print: ["14.print"],
498
+ downloads: ["14.downloads", "6.download"],
499
+ forms: ["14.forms"],
500
+ "link-schemes": ["14.link-schemes"],
501
+ windows: ["14.windows", "6.open"],
502
+ devices: ["14.devices"],
503
+ workers: ["14.workers", "3.webrtc"],
504
+ sound: ["14.sound"],
505
+ address: ["14.address", "served.domain.address"],
506
+ shell: ["14.shell", "4.document-write", "4.light-dom"],
507
+ storage: ["14.storage", "served.domain.storage-scope"],
508
+ };