@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
@@ -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 accepted = false;
145
+ let answer = false;
142
146
  try {
143
- accepted = await options.confirm({ capability, args, display });
147
+ answer = await options.confirm({ capability, args, display }, () => void run());
144
148
  }
145
149
  catch {
146
- accepted = false;
150
+ answer = effect === null ? false : "accepted";
147
151
  }
148
- if (!accepted) {
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
  }
@@ -29,10 +29,24 @@ export type ConfirmRequest = {
29
29
  };
30
30
  }[CapabilityName];
31
31
  /**
32
- * Asks a person. Resolves true only if they accept. The host's UI, or the React binding's default
33
- * dialog; drawn outside the frame, never inside it.
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 Confirm = (request: ConfirmRequest) => Promise<boolean>;
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. */
@@ -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. It sets no `default-src`: it restricts frames only, leaving the host's own
52
- * scripts, styles and fetches to the host's policy. `child-src` repeats `frame-src` for engines
53
- * that read frames from it.
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;
@@ -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
- return ["allow-scripts", "allow-same-origin", "allow-forms"];
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. It sets no `default-src`: it restricts frames only, leaving the host's own
96
- * scripts, styles and fetches to the host's policy. `child-src` repeats `frame-src` for engines
97
- * that read frames from it.
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
- const all = sources.length === 0 ? ["'none'"] : sources;
102
- return serializeCsp([
103
- ["frame-src", ...all],
104
- ["child-src", ...all],
105
- ]);
102
+ return serializeCsp([["frame-src", ...(sources.length === 0 ? ["'none'"] : sources)]]);
106
103
  }
@@ -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
  }
@@ -1 +1,11 @@
1
- export {};
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 { PortholeConfig } from "./schema.ts";
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;
@@ -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 false;
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 false;
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 false;
40
+ return `${JSON.stringify(value)} has no valid port`;
35
41
  }
36
42
  if (host !== "[::1]" && !HOST.test(host)) {
37
- return false;
43
+ return `${JSON.stringify(value)} ${shape}`;
38
44
  }
39
45
  if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
40
- return false;
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 true;
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
- if (!isOriginSource(source)) {
68
- fail(`${path}[${index}]`, `${JSON.stringify(source)} is not an origin (scheme://host[:port], lowercase, no path)`);
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 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,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
- /** Registers every probe as a test, grouped by egress item. */
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
- /** Registers every probe as a test, grouped by egress item. */
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 probe of PROBES) {
825
- test(`[${probe.id}] ${probe.title}`, async ({ page, browserName }) => {
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
- const reason = options.skip?.[id];
78
- // Playwright reads the fixtures a test uses from its parameter's destructuring.
79
- typed(`${id}: ${title}`, async ({ page, browserName }) => {
80
- typed.skip(reason !== undefined, reason);
81
- await body({ page, browserName });
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
- typed.describe(`served modes (${host.name})`, () => {
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
  }