@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.
Files changed (68) hide show
  1. package/dist/bridge/bridge.js +20 -6
  2. package/dist/bridge/types.d.ts +19 -3
  3. package/dist/compile/policy.d.ts +3 -3
  4. package/dist/compile/policy.js +6 -9
  5. package/dist/config/schema.d.ts +21 -0
  6. package/dist/config/schema.js +11 -1
  7. package/dist/config/validate.d.ts +3 -1
  8. package/dist/config/validate.js +41 -10
  9. package/dist/conformance/behavior.js +50 -0
  10. package/dist/conformance/index.d.ts +11 -5
  11. package/dist/conformance/index.js +2 -2
  12. package/dist/conformance/probes.d.ts +26 -2
  13. package/dist/conformance/probes.js +51 -40
  14. package/dist/conformance/served.d.ts +7 -0
  15. package/dist/conformance/served.js +31 -7
  16. package/dist/guide/guide.js +6 -2
  17. package/dist/host/create-host.js +46 -2
  18. package/dist/host/locate.js +6 -1
  19. package/dist/host/outcome.d.ts +24 -0
  20. package/dist/host/outcome.js +134 -0
  21. package/dist/host/types.d.ts +34 -2
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.js +3 -1
  24. package/dist/isolation/served.d.ts +3 -0
  25. package/dist/isolation/served.js +20 -0
  26. package/dist/isolation/types.d.ts +9 -0
  27. package/dist/isolation/types.js +2 -1
  28. package/dist/platform/types.d.ts +7 -0
  29. package/dist/platform/web.js +15 -2
  30. package/dist/presets/presets.d.ts +9 -3
  31. package/dist/presets/presets.js +16 -1
  32. package/dist/react/frame.d.ts +16 -0
  33. package/dist/react/frame.js +44 -10
  34. package/dist/server/pages.d.ts +11 -6
  35. package/dist/server/pages.js +78 -12
  36. package/dist/server/server.js +34 -10
  37. package/dist/shell/document.d.ts +3 -2
  38. package/dist/shell/document.js +4 -3
  39. package/dist/shell/plan.d.ts +2 -0
  40. package/dist/shell/plan.js +1 -0
  41. package/dist/shell/runtime.js +20 -4
  42. package/package.json +1 -9
  43. package/src/bridge/bridge.ts +28 -6
  44. package/src/bridge/types.ts +21 -3
  45. package/src/compile/policy.ts +6 -9
  46. package/src/config/schema.ts +28 -0
  47. package/src/config/validate.ts +48 -14
  48. package/src/conformance/behavior.ts +55 -0
  49. package/src/conformance/index.ts +18 -4
  50. package/src/conformance/probes.ts +88 -52
  51. package/src/conformance/served.ts +37 -8
  52. package/src/guide/guide.ts +8 -2
  53. package/src/host/create-host.ts +47 -2
  54. package/src/host/locate.ts +6 -1
  55. package/src/host/outcome.ts +159 -0
  56. package/src/host/types.ts +33 -2
  57. package/src/index.ts +11 -1
  58. package/src/isolation/served.ts +24 -0
  59. package/src/isolation/types.ts +15 -1
  60. package/src/platform/types.ts +7 -0
  61. package/src/platform/web.ts +15 -2
  62. package/src/presets/presets.ts +27 -3
  63. package/src/react/frame.tsx +78 -14
  64. package/src/server/pages.ts +97 -11
  65. package/src/server/server.ts +48 -8
  66. package/src/shell/document.ts +7 -6
  67. package/src/shell/plan.ts +3 -0
  68. package/src/shell/runtime.ts +20 -4
@@ -71,6 +71,13 @@ export interface ThemeConfig {
71
71
  readonly variables: Readonly<Record<string, ThemeValue>>;
72
72
  /** The window event the shell fires after the theme changed, for code that must redraw. */
73
73
  readonly event: string;
74
+ /**
75
+ * Whether a whole document (an artifact page) takes the person's light or dark scheme:
76
+ * `declared` (default): only a page that declares it supports both
77
+ * (`<meta name="color-scheme" content="light dark">`); any other page shows as its author
78
+ * styled it, in light. `always`: every page. The variables are there either way.
79
+ */
80
+ readonly documents?: "declared" | "always";
74
81
  }
75
82
 
76
83
  export interface Limits {
@@ -88,8 +95,27 @@ export interface Limits {
88
95
  readonly maxPromptChars: number;
89
96
  /** The most errors one run of a display reports. */
90
97
  readonly maxErrorReports: number;
98
+ /**
99
+ * How long a display may take to load before its outcome is a timeout (ms). Optional; 15 s
100
+ * when absent.
101
+ */
102
+ readonly loadTimeoutMs?: number;
91
103
  }
92
104
 
105
+ /**
106
+ * Sandbox tokens a url-mode frame may add to its baseline, by the host's choice (P037): a
107
+ * person's own running app (a dev server's preview) may need popups (an OAuth flow), modals and
108
+ * downloads. Never top navigation, and nothing for other modes.
109
+ */
110
+ export const URL_SANDBOX_EXTRAS = [
111
+ "allow-popups",
112
+ "allow-popups-to-escape-sandbox",
113
+ "allow-modals",
114
+ "allow-downloads",
115
+ ] as const;
116
+
117
+ export type UrlSandboxExtra = (typeof URL_SANDBOX_EXTRAS)[number];
118
+
93
119
  /** Where the host shows the content, for the manual's size rules. */
94
120
  export interface LayoutConfig {
95
121
  /** The narrowest the frame ever is (a small phone). */
@@ -137,4 +163,6 @@ export interface PortholeConfig {
137
163
  */
138
164
  readonly contentOrigins: readonly string[];
139
165
  readonly embedder: EmbedderConfig;
166
+ /** Url mode only: sandbox tokens added to its baseline (`URL_SANDBOX_EXTRAS`). */
167
+ readonly sandbox?: readonly UrlSandboxExtra[];
140
168
  }
@@ -1,5 +1,5 @@
1
1
  import { CAPABILITY_NAMES, isCapabilityName, type Gate } from "./capabilities.ts";
2
- import type { PortholeConfig } from "./schema.ts";
2
+ import { URL_SANDBOX_EXTRAS, type PortholeConfig } from "./schema.ts";
3
3
 
4
4
  /**
5
5
  * Checks a config before anything is derived from it. Every string that ends up in a CSP, an
@@ -30,24 +30,30 @@ const HOST =
30
30
  * every subdomain) and an optional port, nothing else. Insecure schemes only for loopback names.
31
31
  */
32
32
  export function isOriginSource(value: unknown): value is string {
33
+ return originSourceProblem(value) === null;
34
+ }
35
+
36
+ /** Why `value` isn't an origin source Porthole accepts, in words to act on; null if it is. */
37
+ export function originSourceProblem(value: unknown): string | null {
38
+ const shape = "is not an origin (scheme://host[:port], lowercase, no path)";
33
39
  if (typeof value !== "string" || value.length > 253 + 16) {
34
- return false;
40
+ return `${JSON.stringify(value)} ${shape}`;
35
41
  }
36
42
  const match = /^(https|http|wss|ws):\/\/([^/:]+|\[::1\])(:(\d{1,5}))?$/u.exec(value);
37
43
  if (match === null) {
38
- return false;
44
+ return `${JSON.stringify(value)} ${shape}`;
39
45
  }
40
46
  const [, scheme, host = "", , port] = match;
41
47
  if (port !== undefined && (Number(port) < 1 || Number(port) > 65_535)) {
42
- return false;
48
+ return `${JSON.stringify(value)} has no valid port`;
43
49
  }
44
50
  if (host !== "[::1]" && !HOST.test(host)) {
45
- return false;
51
+ return `${JSON.stringify(value)} ${shape}`;
46
52
  }
47
53
  if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
48
- return false;
54
+ 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"}`;
49
55
  }
50
- return true;
56
+ return null;
51
57
  }
52
58
 
53
59
  const fail = (path: string, message: string): never => {
@@ -78,11 +84,9 @@ function sources(value: unknown, path: string): void {
78
84
  return;
79
85
  }
80
86
  value.forEach((source, index) => {
81
- if (!isOriginSource(source)) {
82
- fail(
83
- `${path}[${index}]`,
84
- `${JSON.stringify(source)} is not an origin (scheme://host[:port], lowercase, no path)`,
85
- );
87
+ const problem = originSourceProblem(source);
88
+ if (problem !== null) {
89
+ fail(`${path}[${index}]`, problem);
86
90
  }
87
91
  });
88
92
  }
@@ -125,6 +129,7 @@ export function validateConfig(input: unknown): PortholeConfig {
125
129
  "guide",
126
130
  "contentOrigins",
127
131
  "embedder",
132
+ "sandbox",
128
133
  ],
129
134
  "config",
130
135
  );
@@ -173,7 +178,14 @@ export function validateConfig(input: unknown): PortholeConfig {
173
178
  }
174
179
 
175
180
  const theme = object(config.theme, "config.theme");
176
- only(theme, ["variables", "event"], "config.theme");
181
+ only(theme, ["variables", "event", "documents"], "config.theme");
182
+ if (
183
+ theme.documents !== undefined &&
184
+ theme.documents !== "declared" &&
185
+ theme.documents !== "always"
186
+ ) {
187
+ fail("config.theme.documents", 'must be "declared" or "always"');
188
+ }
177
189
  const variables = object(theme.variables, "config.theme.variables");
178
190
  for (const [name, value] of Object.entries(variables)) {
179
191
  const path = `config.theme.variables[${JSON.stringify(name)}]`;
@@ -212,10 +224,13 @@ export function validateConfig(input: unknown): PortholeConfig {
212
224
  "maxPromptChars",
213
225
  "maxErrorReports",
214
226
  ];
215
- only(limits, limitNames, "config.limits");
227
+ only(limits, [...limitNames, "loadTimeoutMs"], "config.limits");
216
228
  for (const name of limitNames) {
217
229
  positive(limits[name], `config.limits.${name}`);
218
230
  }
231
+ if (limits.loadTimeoutMs !== undefined) {
232
+ positive(limits.loadTimeoutMs, "config.limits.loadTimeoutMs", 600_000);
233
+ }
219
234
  if ((limits.minHeightPx as number) > (limits.maxHeightPx as number)) {
220
235
  fail("config.limits.minHeightPx", "must not exceed maxHeightPx");
221
236
  }
@@ -280,5 +295,24 @@ export function validateConfig(input: unknown): PortholeConfig {
280
295
  "config.embedder.frameSrc",
281
296
  );
282
297
 
298
+ if (config.sandbox !== undefined) {
299
+ if (config.mode !== "url") {
300
+ fail("config.sandbox", "only url mode may add sandbox tokens");
301
+ }
302
+ if (!Array.isArray(config.sandbox)) {
303
+ fail("config.sandbox", "must be an array of sandbox tokens");
304
+ }
305
+ (config.sandbox as unknown[]).forEach((token, index) => {
306
+ if (!(URL_SANDBOX_EXTRAS as readonly unknown[]).includes(token)) {
307
+ fail(
308
+ `config.sandbox[${index}]`,
309
+ /top-navigation|same-origin/u.test(String(token))
310
+ ? `${JSON.stringify(token)} is never granted (P037)`
311
+ : `must be one of ${URL_SANDBOX_EXTRAS.join(", ")}`,
312
+ );
313
+ }
314
+ });
315
+ }
316
+
283
317
  return input as PortholeConfig;
284
318
  }
@@ -295,6 +295,61 @@ export const BEHAVIOR_PROBES: readonly Probe[] = [
295
295
  },
296
296
  },
297
297
 
298
+ {
299
+ id: "11.document-theme",
300
+ item: 11,
301
+ title:
302
+ "a whole page takes the person's scheme only when it declares it supports it (or the host says always)",
303
+ async run(context) {
304
+ const page = (head: string, id: string) =>
305
+ `<!doctype html><html><head><title>Page</title>${head}</head><body><p id="${id}">page</p></body></html>`;
306
+ const scheme = (display: LaunchedDisplay, id: string) =>
307
+ display.frame().then(async (frame) => {
308
+ await expect(frame.locator(`#${id}`)).toHaveText("page");
309
+ return frame.evaluate(() => ({
310
+ scheme: getComputedStyle(document.documentElement).colorScheme,
311
+ theme: document.documentElement.getAttribute("data-theme"),
312
+ variable:
313
+ getComputedStyle(document.documentElement)
314
+ .getPropertyValue("--color-text-primary")
315
+ .trim() !== "",
316
+ }));
317
+ });
318
+ const plain = await context.launch({
319
+ config: presets.artifact(),
320
+ scheme: "dark",
321
+ content: page("", "plain"),
322
+ });
323
+ // An author's own page, declaring nothing: shown as styled, in light; the variables are there.
324
+ expect(await scheme(plain, "plain")).toEqual({
325
+ scheme: "normal",
326
+ theme: null,
327
+ variable: true,
328
+ });
329
+ await plain.setTheme("light");
330
+ await plain.setTheme("dark");
331
+ await settle(300);
332
+ expect(await scheme(plain, "plain")).toEqual({
333
+ scheme: "normal",
334
+ theme: null,
335
+ variable: true,
336
+ });
337
+ const declared = await context.launch({
338
+ config: presets.artifact(),
339
+ scheme: "dark",
340
+ content: page('<meta name="color-scheme" content="light dark">', "declared"),
341
+ });
342
+ await expect.poll(async () => (await scheme(declared, "declared")).theme).toBe("dark");
343
+ expect((await scheme(declared, "declared")).scheme).toBe("dark");
344
+ const always = await context.launch({
345
+ config: presets.artifact({ theme: { documents: "always" } }),
346
+ scheme: "dark",
347
+ content: page("", "always"),
348
+ });
349
+ await expect.poll(async () => (await scheme(always, "always")).theme).toBe("dark");
350
+ },
351
+ },
352
+
298
353
  // ── 12: the host-function gates ────────────────────────────────────────────────────────────
299
354
  {
300
355
  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 suite in its Playwright project:
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 { defineLeakSuite } from "@botiverse/porthole/conformance";
8
- * defineLeakSuite(test, myHost);
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,12 +96,20 @@ export interface LeakSuiteOptions {
90
96
  readonly skip?: Readonly<Record<string, string>>;
91
97
  }
92
98
 
93
- export { defineLeakSuite, PROBES, type Probe } from "./probes.ts";
99
+ export {
100
+ defineLeakSuite,
101
+ leakTests,
102
+ PROBES,
103
+ type ConformanceFixtures,
104
+ type ConformanceTest,
105
+ type Probe,
106
+ } from "./probes.ts";
94
107
  export { httpOracle, settle, udpOracle, type HttpOracle, type UdpOracle } from "./oracles.ts";
95
108
 
96
109
  // The served modes' cases (domain and url mode), with their own host interface.
97
110
  export {
98
111
  defineServedSuite,
112
+ servedTests,
99
113
  type ServedConformanceHost,
100
114
  type ServedDisplay,
101
115
  type ServedSuiteOptions,
@@ -4,6 +4,8 @@ import {
4
4
  type PlaywrightTestOptions,
5
5
  type PlaywrightWorkerArgs,
6
6
  type PlaywrightWorkerOptions,
7
+ type Page,
8
+ type TestInfo,
7
9
  type TestType,
8
10
  } from "@playwright/test";
9
11
  import { presets } from "../presets/presets.ts";
@@ -1030,64 +1032,98 @@ type AnyTest = TestType<
1030
1032
  PlaywrightWorkerArgs & PlaywrightWorkerOptions
1031
1033
  >;
1032
1034
 
1033
- /** Registers every probe as a test, grouped by egress item. */
1035
+ /** The fixtures a conformance test uses. */
1036
+ export interface ConformanceFixtures {
1037
+ readonly page: Page;
1038
+ readonly browserName: string;
1039
+ }
1040
+
1041
+ /**
1042
+ * One conformance test, for a host's own spec file to register: its tests are then located in that
1043
+ * file, so `playwright test my-conformance.spec.ts` (or `--grep "\[1\.connect\]"`) finds them.
1044
+ *
1045
+ * for (const each of leakTests(myHost)) {
1046
+ * test(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
1047
+ * }
1048
+ */
1049
+ export interface ConformanceTest {
1050
+ readonly id: string;
1051
+ /** `[<id>] <what it proves>`: the probe id is greppable. */
1052
+ readonly title: string;
1053
+ run(fixtures: ConformanceFixtures, info: TestInfo): Promise<void>;
1054
+ }
1055
+
1056
+ /** Every probe as a test entry (see `ConformanceTest`). */
1057
+ export function leakTests(
1058
+ host: ConformanceHost,
1059
+ options: LeakSuiteOptions = {},
1060
+ ): ConformanceTest[] {
1061
+ return PROBES.map((probe) => ({
1062
+ id: probe.id,
1063
+ title: `[${probe.id}] ${probe.title}`,
1064
+ async run({ page, browserName }, info) {
1065
+ const reason = options.skip?.[probe.id];
1066
+ info.skip(reason !== undefined, reason);
1067
+ const closers: (() => Promise<void>)[] = [];
1068
+ const requests: string[] = [];
1069
+ page.on("response", (response) => requests.push(response.url()));
1070
+ try {
1071
+ await probe.run({
1072
+ page,
1073
+ engine: browserName,
1074
+ launch: (spec) => host.launch(page, spec),
1075
+ http: async (files) => {
1076
+ const oracle = await httpOracle(files);
1077
+ closers.push(() => oracle.close());
1078
+ return oracle;
1079
+ },
1080
+ udp: async () => {
1081
+ const oracle = await udpOracle();
1082
+ closers.push(() => oracle.close());
1083
+ return oracle;
1084
+ },
1085
+ result: async (display, timeoutMs = 10_000) => {
1086
+ await expect
1087
+ .poll(
1088
+ async () =>
1089
+ (await display.frame())
1090
+ .evaluate(() =>
1091
+ Boolean(
1092
+ (window as unknown as { portholeProbe?: { done?: boolean } }).portholeProbe
1093
+ ?.done,
1094
+ ),
1095
+ )
1096
+ .catch(() => false),
1097
+ { timeout: timeoutMs },
1098
+ )
1099
+ .toBe(true);
1100
+ return (await display.frame()).evaluate(
1101
+ () => (window as unknown as { portholeProbe: Record<string, unknown> }).portholeProbe,
1102
+ );
1103
+ },
1104
+ requestsTo: (origin) =>
1105
+ requests.filter((url) => url.startsWith(`${origin}/`) || url === origin),
1106
+ measured: (text) => info.annotations.push({ type: "measured", description: text }),
1107
+ });
1108
+ } finally {
1109
+ await Promise.all(closers.map((close) => close()));
1110
+ }
1111
+ },
1112
+ }));
1113
+ }
1114
+
1115
+ /**
1116
+ * Registers every probe as a test. Its tests are located in Porthole's own files; a host that
1117
+ * wants to select them by its spec file registers `leakTests` itself instead.
1118
+ */
1034
1119
  export function defineLeakSuite(
1035
1120
  test: AnyTest,
1036
1121
  host: ConformanceHost,
1037
1122
  options: LeakSuiteOptions = {},
1038
1123
  ): void {
1039
1124
  test.describe(`Porthole leak suite (${host.name})`, () => {
1040
- for (const probe of PROBES) {
1041
- test(`[${probe.id}] ${probe.title}`, async ({ page, browserName }) => {
1042
- const reason = options.skip?.[probe.id];
1043
- test.skip(reason !== undefined, reason);
1044
- const closers: (() => Promise<void>)[] = [];
1045
- const requests: string[] = [];
1046
- page.on("response", (response) => requests.push(response.url()));
1047
- try {
1048
- await probe.run({
1049
- page,
1050
- engine: browserName,
1051
- launch: (spec) => host.launch(page, spec),
1052
- http: async (files) => {
1053
- const oracle = await httpOracle(files);
1054
- closers.push(() => oracle.close());
1055
- return oracle;
1056
- },
1057
- udp: async () => {
1058
- const oracle = await udpOracle();
1059
- closers.push(() => oracle.close());
1060
- return oracle;
1061
- },
1062
- result: async (display, timeoutMs = 10_000) => {
1063
- await expect
1064
- .poll(
1065
- async () =>
1066
- (await display.frame())
1067
- .evaluate(() =>
1068
- Boolean(
1069
- (window as unknown as { portholeProbe?: { done?: boolean } })
1070
- .portholeProbe?.done,
1071
- ),
1072
- )
1073
- .catch(() => false),
1074
- { timeout: timeoutMs },
1075
- )
1076
- .toBe(true);
1077
- return (await display.frame()).evaluate(
1078
- () =>
1079
- (window as unknown as { portholeProbe: Record<string, unknown> }).portholeProbe,
1080
- );
1081
- },
1082
- requestsTo: (origin) =>
1083
- requests.filter((url) => url.startsWith(`${origin}/`) || url === origin),
1084
- measured: (text) =>
1085
- test.info().annotations.push({ type: "measured", description: text }),
1086
- });
1087
- } finally {
1088
- await Promise.all(closers.map((close) => close()));
1089
- }
1090
- });
1125
+ for (const each of leakTests(host, options)) {
1126
+ test(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
1091
1127
  }
1092
1128
  });
1093
1129
  }
@@ -11,7 +11,8 @@
11
11
  * Each case attempts one escape or access channel and asserts, with an out-of-band oracle where
12
12
  * one exists (a canary URL the host counts requests to), that nothing got through.
13
13
  */
14
- import { expect, type Frame, type Page, type TestType } from "@playwright/test";
14
+ import { expect, type Frame, type Page, type TestInfo, 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
  import { MCP_APPS_METHODS } from "../protocol/mcp-apps.ts";
@@ -137,22 +138,49 @@ async function plantHostSecrets(page: Page): Promise<void> {
137
138
  }
138
139
 
139
140
  /** Registers every served-mode case as a test. */
141
+ /**
142
+ * Registers every served-mode case as a test. A host that wants to select them by its own spec
143
+ * file registers `servedTests` itself (see `ConformanceTest`).
144
+ */
140
145
  export function defineServedSuite(
141
146
  test: TestType<object, object>,
142
147
  host: ServedConformanceHost,
143
148
  options: ServedSuiteOptions = {},
144
149
  ): void {
145
150
  const typed = test as unknown as TestType<Fixtures, object>;
151
+ typed.describe(`served modes (${host.name})`, () => {
152
+ for (const each of servedTests(host, options)) {
153
+ // Playwright reads the fixtures a test uses from its parameter's destructuring.
154
+ typed(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
155
+ }
156
+ });
157
+ }
158
+
159
+ /** Every served-mode case as a test entry (see `ConformanceTest`). */
160
+ export function servedTests(
161
+ host: ServedConformanceHost,
162
+ options: ServedSuiteOptions = {},
163
+ ): ConformanceTest[] {
164
+ const tests: ConformanceTest[] = [];
165
+ let info: TestInfo | null = null;
166
+ // The case being run's own TestInfo (for an expected failure measured on one engine).
167
+ const typed = {
168
+ fail: (condition: boolean, reason: string) => info?.fail(condition, reason),
169
+ };
146
170
  const define = (id: string, title: string, body: (fixtures: Fixtures) => Promise<void>) => {
147
- const reason = options.skip?.[id];
148
- // Playwright reads the fixtures a test uses from its parameter's destructuring.
149
- typed(`${id}: ${title}`, async ({ page, browserName }) => {
150
- typed.skip(reason !== undefined, reason);
151
- await body({ page, browserName });
171
+ tests.push({
172
+ id,
173
+ title: `${id}: ${title}`,
174
+ async run({ page, browserName }, testInfo) {
175
+ const reason = options.skip?.[id];
176
+ testInfo.skip(reason !== undefined, reason);
177
+ info = testInfo;
178
+ await body({ page, browserName });
179
+ },
152
180
  });
153
181
  };
154
182
 
155
- typed.describe(`served modes (${host.name})`, () => {
183
+ {
156
184
  const modes = [
157
185
  ["domain", (scope: string) => host.shell(scope)],
158
186
  ["url", (scope: string) => host.app(scope)],
@@ -468,5 +496,6 @@ export function defineServedSuite(
468
496
  .toBe("ready");
469
497
  },
470
498
  );
471
- });
499
+ }
500
+ return tests;
472
501
  }
@@ -107,6 +107,10 @@ function network(config: PortholeConfig): string {
107
107
  return `Network: only ${parts.join("; ")}. Anything else fails.`;
108
108
  }
109
109
 
110
+ /** Whether a whole document takes the theme only when it declares support (ThemeConfig.documents). */
111
+ const optsIn = (config: PortholeConfig): boolean =>
112
+ config.mode === "artifact" && (config.theme.documents ?? "declared") === "declared";
113
+
110
114
  const loadsScripts = (config: PortholeConfig): boolean =>
111
115
  egressOrigins(config, "script").length > 0;
112
116
 
@@ -157,7 +161,7 @@ function widget(c: Context): string {
157
161
  "",
158
162
  what,
159
163
  "",
160
- `Where it runs: in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the box's clientWidth, again from a ResizeObserver). ${height} It runs in the person's light or dark theme, and again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`,
164
+ `Where it runs: in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the box's clientWidth, again from a ResizeObserver). ${height} ${optsIn(config) ? `It shows in light, as you style it; to follow the person's light or dark theme, declare it in its head (<meta name="color-scheme" content="light dark">) and draw with the variables below.` : "It runs in the person's light or dark theme."} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`,
161
165
  "",
162
166
  "Sizes that read on a phone:",
163
167
  c.when(
@@ -173,7 +177,9 @@ function widget(c: Context): string {
173
177
  ? "\nLibraries: pin an exact version at least two weeks old (chart.js@4.4.1 in its URL), never @latest or a range, so the code can't be swapped under you and draws the same every time."
174
178
  : null,
175
179
  "",
176
- `Colors: every color from these variables, never hard-coded, so light and dark both work. The ${noun}'s own background is transparent (it sits on the app's surface).`,
180
+ optsIn(config)
181
+ ? `Colors, for a ${noun} that declares light and dark: every color from these variables, never hard-coded, so both work; its own background then stays transparent (it sits on the app's surface).`
182
+ : `Colors: every color from these variables, never hard-coded, so light and dark both work. The ${noun}'s own background is transparent (it sits on the app's surface).`,
177
183
  c.names("--color-background-") === "" ? null : `- Surfaces: ${c.names("--color-background-")}`,
178
184
  c.names("--color-text-") === "" ? null : `- Text: ${c.names("--color-text-")}`,
179
185
  c.names("--color-border-") === ""
@@ -4,6 +4,7 @@ import { declaredCapabilities } from "../compile/policy.ts";
4
4
  import { capabilitySpec } from "../config/capabilities.ts";
5
5
  import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
6
6
  import { locateServed } from "./locate.ts";
7
+ import { watchOutcome, type OutcomeWatch } from "./outcome.ts";
7
8
  import type { FrameSource } from "../isolation/types.ts";
8
9
  import { webPlatform } from "../platform/web.ts";
9
10
  import type { Surface } from "../platform/types.ts";
@@ -23,6 +24,9 @@ import type {
23
24
  PortholeHost,
24
25
  } from "./types.ts";
25
26
 
27
+ /** How long a display may take to load before its outcome is a timeout, by default. */
28
+ const DEFAULT_LOAD_TIMEOUT_MS = 15_000;
29
+
26
30
  /** How many times a display is started again after its content navigated its frame away. */
27
31
  const MAX_RESTARTS = 3;
28
32
 
@@ -81,12 +85,14 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
81
85
  let restarts = 0;
82
86
  let generation = 0;
83
87
  let abort: AbortController | null = null;
88
+ let outcomeWatch: OutcomeWatch | null = null;
89
+ let themeFrame: number | null = null;
90
+ const view = container.ownerDocument.defaultView;
84
91
  // The adapter's own operations, the last call of each, replayed into every new frame.
85
92
  const replay = new Map<string, unknown[]>();
86
93
 
87
94
  const hostContext = (): HostContext => {
88
95
  const width = container.getBoundingClientRect().width;
89
- const view = container.ownerDocument.defaultView;
90
96
  // The spec's names in `styles` (the App SDK refuses others there), the rest in our field.
91
97
  const variables = resolveTheme(config.theme, scheme, options.theme?.(scheme));
92
98
  const standard: Record<string, string> = {};
@@ -142,6 +148,8 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
142
148
  const stop = () => {
143
149
  abort?.abort();
144
150
  abort = null;
151
+ outcomeWatch?.stop();
152
+ outcomeWatch = null;
145
153
  connection?.close();
146
154
  connection = null;
147
155
  surface?.destroy();
@@ -156,10 +164,29 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
156
164
  allow: compiled.allow,
157
165
  referrerPolicy: compiled.referrerPolicy,
158
166
  colorScheme: scheme,
159
- initialHeightPx: height,
167
+ // An app reports no size: its frame fills the host's container, at least the minimum.
168
+ ...(speaks
169
+ ? { initialHeightPx: height }
170
+ : { fill: true, initialHeightPx: config.limits.minHeightPx }),
160
171
  });
161
172
  surface = created;
173
+ const watch = watchOutcome({
174
+ surface: created,
175
+ source,
176
+ speaks,
177
+ timeoutMs: config.limits.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS,
178
+ onOutcome: (outcome) => emit("outcome", outcome),
179
+ });
180
+ outcomeWatch = watch;
162
181
  if (!speaks) {
182
+ // No handshake in url mode: the app's first document loading is as ready as it gets.
183
+ let ready = false;
184
+ created.onLoad(() => {
185
+ if (!ready) {
186
+ ready = true;
187
+ emit("initialized", undefined);
188
+ }
189
+ });
163
190
  return;
164
191
  }
165
192
  const bridge = createBridge({
@@ -179,6 +206,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
179
206
  hostContext,
180
207
  emit: (event) => {
181
208
  if (event.type === "initialized") {
209
+ watch.initialized();
182
210
  emit("initialized", undefined);
183
211
  }
184
212
  },
@@ -249,6 +277,23 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
249
277
  setTheme(next: ColorScheme) {
250
278
  scheme = next;
251
279
  surface?.setColorScheme(next);
280
+ // Read the host's values two frames later: a host that restyles its page for the new
281
+ // scheme on its next frame has done so by then.
282
+ if (view === null) {
283
+ connection?.hostContextChanged(hostContext());
284
+ return;
285
+ }
286
+ if (themeFrame !== null) {
287
+ view.cancelAnimationFrame(themeFrame);
288
+ }
289
+ themeFrame = view.requestAnimationFrame(() => {
290
+ themeFrame = view.requestAnimationFrame(() => {
291
+ themeFrame = null;
292
+ connection?.hostContextChanged(hostContext());
293
+ });
294
+ });
295
+ },
296
+ refreshTheme() {
252
297
  connection?.hostContextChanged(hostContext());
253
298
  },
254
299
  on<E extends keyof DisplayEvents>(event: E, listener: (detail: DisplayEvents[E]) => void) {
@@ -16,5 +16,10 @@ export async function locateServed(
16
16
  ): Promise<FrameSource> {
17
17
  const location = await locator.locate(instance, signal);
18
18
  checkFrameLocation(location, config.contentOrigins, hostOrigin);
19
- return { kind: "url", url: new URL(location.url).href, origin: location.origin };
19
+ return {
20
+ kind: "url",
21
+ url: new URL(location.url).href,
22
+ origin: location.origin,
23
+ ...(location.reports === true ? { reports: true } : {}),
24
+ };
20
25
  }