@botiverse/porthole 0.2.1 → 0.2.2

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.
@@ -0,0 +1,116 @@
1
+ # Objectives
2
+
3
+ ## What Porthole is for
4
+
5
+ Any app that wants the "show content made by an agent" ability — like the inline
6
+ visuals and standalone pages in ChatGPT and the Claude app — should be able to
7
+ embed Porthole and get a working, safe display out of the box, plus the manual
8
+ that teaches the agent to write for it.
9
+
10
+ The library has two layers:
11
+
12
+ - a **framework layer** of mechanisms (isolation, egress hardening, the in-frame
13
+ shell, streaming, sizing, theming, the host-function bridge, the guide
14
+ generator); and
15
+ - a **preset policy layer** of ready-made bundles a host adopts with near-zero
16
+ config: **inline widget**, **artifact** (a standalone page), **MCP Apps** (the
17
+ standard protocol), and **URL** (a running app framed by URL, such as a dev
18
+ server's preview).
19
+
20
+ The subject, stated generally, is **HTML drawn by an agent that may optionally
21
+ interact with the agent** — and that interaction is just one host function among
22
+ several. "Out of the box" means: a host picks a preset, passes theme tokens and
23
+ callbacks, and receives a safe display plus a generated agent manual.
24
+
25
+ ## Objectives and success criteria
26
+
27
+ 1. **Show untrusted agent HTML without putting the host at risk.**
28
+ - *Success:* the conformance/leak suite passes on Chromium, WebKit and
29
+ Firefox — every egress and escape probe attempts its channel and observes
30
+ nothing; the content cannot reach the host origin, its storage, its DOM, or
31
+ its credentials.
32
+
33
+ 2. **One config generates the sandbox, the shell, and the manual, in agreement.**
34
+ - *Success:* for every preset, a test derives the CSP, the shell's wired host
35
+ functions, and the generated guide from the same config and asserts they
36
+ match — the guide names only variables the shell injects and only
37
+ capabilities the CSP and bridge allow; changing the config changes all
38
+ three together.
39
+
40
+ 3. **Speak the MCP Apps standard, proven against the reference implementation.**
41
+ - *Success:* the MCP Apps preset renders and interacts with the official
42
+ ext-apps example servers **unmodified** (initialize handshake, tool input
43
+ and result, size and theme changes, open-link and message), driven in the
44
+ leak suite.
45
+
46
+ 4. **Embed with near-zero config across the modes.**
47
+ - *Success:* a host mounts an inline widget, an artifact page, an MCP Apps
48
+ view and a URL frame each in a few lines using only a preset, theme tokens
49
+ and callbacks; the framework-agnostic core and the React binding both do
50
+ this; no host-specific code lives in the core.
51
+
52
+ 5. **Ship the agent manual as a first-class output.**
53
+ - *Success:* each preset produces an agent-facing guide and tool description
54
+ from its config; a real agent that reads the guide produces content that
55
+ renders within the sandbox and reads correctly at the target widths in light
56
+ and dark, checked by rendering the guide's own examples in the leak suite.
57
+
58
+ ## Non-goals
59
+
60
+ - **Host-drawn DSL rendering.** Rendering a JSON/JSX/remote-DOM UI description
61
+ with the host's own native components (A2UI, json-render, remote-dom, JSX
62
+ preview) is a separate concern and a different project. Porthole is the HTML
63
+ sandbox only.
64
+ - **A component vocabulary inside the sandbox.** Content is plain HTML and SVG,
65
+ taught by the generated manual; a consistent look comes from the injected theme
66
+ variables and, at most, plain base CSS for native elements, never from tags and
67
+ props an agent must learn (P015). Nor is Porthole a design-system library.
68
+ - **A new widget-side protocol.** The contract is MCP Apps; we do not define an
69
+ alternative (PRINCIPLE 2).
70
+ - **A shell or egress control for URL mode.** A URL frame is someone else's
71
+ running app: Porthole gives it isolation and access auth, nothing more (P020).
72
+ - **Operating infrastructure.** The serving part (`/server`) is a request handler a
73
+ host mounts in its own backend; Porthole runs no service, domain or certificate
74
+ of its own.
75
+ - **Being an agent framework or an MCP server.** Porthole is host-side rendering
76
+ and isolation. It does not run models, implement an MCP server, or decide
77
+ product policy (PRINCIPLE 8).
78
+
79
+ ## Who the users are
80
+
81
+ - **Host app developers** who want to show agent-made HTML in a chat or canvas UI
82
+ and do not want to build or audit an isolation layer themselves. They are the
83
+ primary users: they embed the library and pick a preset.
84
+ - **Agents (and the people prompting them)** who write the HTML. They are served
85
+ indirectly: the generated manual is written for them, and the sandbox is the
86
+ environment their code runs in.
87
+ - **MCP App authors** who build standard MCP Apps and want them to render in any
88
+ Porthole-based host unmodified.
89
+
90
+ The first adopter is a normal user of the public API, with no privileged path
91
+ (PRINCIPLE 9).
92
+
93
+ ## What "done" means for phase 1
94
+
95
+ Phase 1 is done when:
96
+
97
+ - the framework-agnostic web core and the React binding (with its default,
98
+ replaceable confirmation dialog) exist, with opaque-origin isolation, egress
99
+ hardening, the shell runtime, streaming render, auto-sizing, theming, and the
100
+ host-function bridge;
101
+ - the served modes exist: domain mode (a stable origin per storage scope, CSP in
102
+ headers, ticket → cookie for private content) with its `/server` request
103
+ handler, the artifact page on it, and URL mode (frame, isolation and access
104
+ auth only);
105
+ - the presets exist (inline widget, artifact page, MCP Apps view, URL), each
106
+ generating its CSP and frame policy, and all but URL its shell and manual, from
107
+ one config;
108
+ - the MCP Apps adapter runs the official ext-apps examples unmodified;
109
+ - the leak/conformance suite passes on Chromium and WebKit, then Firefox, and is
110
+ runnable by any host; and
111
+ - the first host has migrated onto it (dogfooding), every one of its features
112
+ that shows agent-made or foreign HTML (widgets, shown HTML files, the dev-server
113
+ preview) going through Porthole, with its own code reduced to configuration
114
+ plus callbacks.
115
+
116
+ Native hosts (WKWebView, maybe Android) are phase 2.
@@ -0,0 +1,155 @@
1
+ # Principles
2
+
3
+ These are the rules we use to make decisions quickly. When a choice is unclear,
4
+ pick the option that best satisfies these, write it down in
5
+ [DECISIONS.md](DECISIONS.md), and move on. Principles change only through a
6
+ decision entry.
7
+
8
+ Each principle gives its reason and what it rules out.
9
+
10
+ ## 1. Security defaults cannot be weakened silently
11
+
12
+ Isolation is on, egress is closed, host functions are off until declared, and
13
+ their gates are on — by default. Any change that loosens a default (widens an
14
+ allowlist, drops a sandbox flag, relaxes the CSP, removes a gate) happens only
15
+ through a decision entry that names the threat it re-opens and ships the test
16
+ that bounds the new risk.
17
+
18
+ - **Why.** The whole point of the library is that a host can hand it untrusted
19
+ HTML without reasoning about browser internals. A quiet loosening moves risk
20
+ onto hosts who never agreed to it.
21
+ - **Rules out.** Convenience flags that trade safety for a smaller config;
22
+ "temporary" allowlist entries without an owner; a default that is safe only if
23
+ the host remembers to also set something.
24
+
25
+ ## 2. The external contract is the standard; we do not invent a widget protocol
26
+
27
+ What the agent-written HTML speaks to its host is **MCP Apps**
28
+ (`io.modelcontextprotocol/ui`). Gaps are filled through the spec's own extension
29
+ points (experimental capabilities, own-prefixed methods), and anything general is
30
+ taken upstream to the spec, not kept as a private dialect.
31
+
32
+ - **Why.** The value of standard agent-UI is that one widget runs in many hosts
33
+ and one host renders many widgets. A bespoke protocol forfeits that and makes
34
+ every author integrate with us specifically.
35
+ - **Rules out.** A new wire format for widgets; forking the protocol to ship a
36
+ feature faster; host-specific globals the widget must detect.
37
+
38
+ ## 3. One config is the single source for the sandbox, the shell, and the manual
39
+
40
+ From one config Porthole derives the CSP, the iframe sandbox and `allow`
41
+ attributes, the in-frame shell, the host-function bridge, **and** the
42
+ agent-facing guide and tool description. They are generated together, never
43
+ edited apart.
44
+
45
+ - **Why.** These four artifacts must agree to be safe and usable: a manual that
46
+ promises a capability the CSP blocks wastes the agent's turns; a CSP that
47
+ allows more than the manual teaches is unaudited surface. Drift between them is
48
+ the most common defect in hand-built sandboxes.
49
+ - **Rules out.** A guide maintained by hand; a CSP string literal that no test
50
+ ties back to the capability list; teaching a variable or function the shell
51
+ does not inject.
52
+
53
+ ## 4. Everything the HTML can do beyond drawing is a declared host function
54
+
55
+ Drawing pixels is free; every other power — send a prompt to the agent, open a
56
+ link, read or write state, call a tool, read data, report an error — is a
57
+ **host function (capability)** named in the config. Each declares its gate (needs
58
+ a user gesture, needs a person's confirmation, is rate-limited), its egress
59
+ needs, and its guide entry. Talking to the agent is just one host function among
60
+ these.
61
+
62
+ - **Why.** This is the core abstraction: "agent-drawn HTML + host functions." It
63
+ gives one list that drives the bridge, the CSP and the manual at once (see
64
+ PRINCIPLE 3), and it makes the question "what can this content do?" answerable
65
+ by reading the config.
66
+ - **Rules out.** Ambient powers the HTML gets without the host opting in; a
67
+ capability wired into the bridge but absent from the CSP or the manual; a
68
+ gate decided ad hoc per call site instead of declared once.
69
+
70
+ ## 5. Every hardening item ships with a test that proves it
71
+
72
+ A claim that the sandbox blocks something is backed by a test in the conformance
73
+ suite that attempts it and observes nothing. A hardening item without a passing
74
+ test does not exist.
75
+
76
+ - **Why.** Browser behavior changes across versions and engines, and egress
77
+ paths are easy to miss by inspection. Only an executed probe shows the wall is
78
+ really there, and keeps it there as browsers move.
79
+ - **Rules out.** "We set the CSP, so it's blocked" without a probe; hardening
80
+ described in a doc but not exercised; a fix landed without first writing the
81
+ test that failed.
82
+
83
+ ## 6. Measured over assumed
84
+
85
+ Browser isolation facts are decided by running the probe on the engines we
86
+ support, not by reading a spec or assuming parity across engines. Where engines
87
+ differ, we harden for the weakest and record the measurement.
88
+
89
+ - **Why.** CSP directives are honored unevenly, and sandbox and frame rules have
90
+ engine-specific gaps. An assumption that is wrong in one engine is a hole in
91
+ production.
92
+ - **Rules out.** Relying on a single directive that one engine ignores; trusting
93
+ that two engines behave the same; shipping a hardening item we have not seen
94
+ pass and fail.
95
+
96
+ ## 7. Host-agnostic outside, protocol-pluggable inside
97
+
98
+ The core knows nothing about any particular host app or vendor. It exposes
99
+ mechanisms and takes a config; the host supplies theme tokens, callbacks and
100
+ policy. Inside, the protocol an adapter speaks is swappable behind a thin seam;
101
+ MCP Apps is the first and default adapter.
102
+
103
+ - **Why.** The library is meant to be embedded by many apps. Baking one app's
104
+ assumptions, or one protocol's object model, into the core makes it that app's
105
+ library, not everyone's.
106
+ - **Rules out.** Importing a host's types into the core; a core that only works
107
+ with one framework; tying the core to one protocol SDK's class hierarchy.
108
+
109
+ ## 8. The host owns product policy; the surface stays small
110
+
111
+ Porthole decides mechanism (how isolation and gating work); the host decides
112
+ policy (whether to confirm an action, what the confirmation says, which origins
113
+ are allowed, what the theme is). We prefer deleting options to adding them, and
114
+ presets over knobs.
115
+
116
+ Authentication and authorization *policy* are the host's; carrying the host's
117
+ decision safely onto the content domain is Porthole's *mechanism*. Porthole never
118
+ knows who someone is or what they may see: the host decides before it issues a
119
+ ticket and on every request after (`authorize`), and Porthole carries that decision
120
+ in tickets and cookies, opaque to it (P046, P050).
121
+
122
+ - **Why.** Policy is product-specific and changes faster than mechanism. A small,
123
+ opinionated surface with safe presets is easier to audit and harder to misuse
124
+ than a large configurable one.
125
+ - **Rules out.** Drawing our own confirmation UI the host can't control; config
126
+ options that exist only to avoid a decision; a second way to do something the
127
+ presets already cover; users, roles, share lists or revocation lists inside
128
+ Porthole.
129
+
130
+ ## 9. No special first user
131
+
132
+ Porthole is built as a general library. Its first adopter is a normal consumer of
133
+ the public API, not a privileged case with private hooks. If the first user needs
134
+ something, it goes into the config or an extension point that any host could use.
135
+
136
+ - **Why.** A library shaped around one caller's internals is not reusable, and
137
+ the shortcuts leak that caller's assumptions into the core (against PRINCIPLE
138
+ 7).
139
+ - **Rules out.** An escape hatch only the first host uses; branching on who the
140
+ caller is; features reachable only through internal, undocumented API.
141
+
142
+ ## 10. The agent manual is a first-class output, from day one
143
+
144
+ The shell is designed together with the manual that teaches an agent to write for
145
+ it. Guide generation is part of the framework layer, not an afterthought: every
146
+ preset ships an agent-facing manual and a tool description generated from the
147
+ same config as the sandbox and the shell.
148
+
149
+ - **Why.** Untrusted HTML is only as good as what the author was told — the sizes
150
+ that read on each screen, the theme variables that exist, what the sandbox
151
+ forbids. A shell without a matching manual produces content that is almost
152
+ right, and the manual must track the shell automatically to stay true.
153
+ - **Rules out.** Shipping a shell and leaving the manual to each host; a manual
154
+ that lists variables or limits the shell does not actually enforce; prose
155
+ guidance that no test checks against real renders.
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@botiverse/porthole",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "A host-side sandbox for agent-made HTML: isolation, egress hardening, a themed shell, gated host functions, and the agent manual, from one config.",
5
5
  "license": "Apache-2.0",
6
6
  "files": [
7
7
  "src",
8
- "dist"
8
+ "dist",
9
+ "docs"
9
10
  ],
10
11
  "type": "module",
11
12
  "sideEffects": false,
@@ -62,7 +63,7 @@
62
63
  "node": ">=24"
63
64
  },
64
65
  "scripts": {
65
- "build": "tsc -p tsconfig.build.json && node scripts/build-harness.ts",
66
+ "build": "tsc -p tsconfig.build.json && node scripts/build-harness.ts && node scripts/copy-docs.ts",
66
67
  "test:leak": "playwright test -c leak/playwright.config.ts"
67
68
  }
68
69
  }
@@ -17,9 +17,15 @@ export interface EntryConformanceHost {
17
17
  * The clean address of a stored page the signed-in viewer may open (`ContentHost.urlOf`), for
18
18
  * `scope`: it draws `<main id="artifact">version 1</main>`.
19
19
  */
20
- url(scope: string): string;
20
+ url(scope: string): string | Promise<string>;
21
21
  /** A clean address the signed-in viewer may not open. */
22
- denied(scope: string): string;
22
+ denied(scope: string): string | Promise<string>;
23
+ /**
24
+ * Addresses on the content domain that name nothing (a label the app never assigns, the bare
25
+ * domain, a path no instance has): each must be Porthole's 404 there, never the app's sign-in
26
+ * (P070). Absent: the case is skipped.
27
+ */
28
+ unknown?(): readonly string[] | Promise<readonly string[]>;
23
29
  /** Whether `page` shows the app's own sign-in. */
24
30
  atSignIn(page: Page): Promise<boolean>;
25
31
  /** On the app's sign-in page: signs in, as a person does (and the app goes back). */
@@ -86,7 +92,7 @@ export function entryTests(
86
92
  "a clean address opens directly for a signed-in viewer, and stays clean; a reload rides on its cookie",
87
93
  async ({ page }) => {
88
94
  await host.signIn(page);
89
- const url = host.url("entry-direct");
95
+ const url = await host.url("entry-direct");
90
96
  await page.goto(url);
91
97
  await expect(page.locator("#artifact")).toHaveText("version 1");
92
98
  expect(page.url(), "the address bar shows the clean address, no code").toBe(url);
@@ -106,7 +112,7 @@ export function entryTests(
106
112
  "served.entry.signed-out",
107
113
  "signed out, a clean address goes to the app's own sign-in, then back to the content",
108
114
  async ({ page }) => {
109
- const url = host.url("entry-signed-out");
115
+ const url = await host.url("entry-signed-out");
110
116
  await page.goto(url);
111
117
  expect(await host.atSignIn(page), "the app's sign-in").toBe(true);
112
118
  expect(await shows(page)).toBe(false);
@@ -121,7 +127,7 @@ export function entryTests(
121
127
  "a viewer without access gets the app's refusal, never the content",
122
128
  async ({ page }) => {
123
129
  await host.signIn(page);
124
- const url = host.denied("entry-denied");
130
+ const url = await host.denied("entry-denied");
125
131
  const answer = await page.goto(url);
126
132
  expect(answer?.status()).toBe(403);
127
133
  expect(await shows(page)).toBe(false);
@@ -129,12 +135,35 @@ export function entryTests(
129
135
  },
130
136
  );
131
137
 
138
+ define(
139
+ "served.entry.unknown",
140
+ "an address that names nothing is a 404 on the content domain: never a trip to the app's sign-in",
141
+ async ({ page }, info) => {
142
+ info.skip(
143
+ host.unknown === undefined,
144
+ "the host gave no addresses that name nothing (unknown)",
145
+ );
146
+ const request = page.context().request;
147
+ for (const url of (await host.unknown?.()) ?? []) {
148
+ const answer = await request.get(url, {
149
+ maxRedirects: 0,
150
+ headers: { "sec-fetch-dest": "document" },
151
+ });
152
+ expect(answer.status(), url).toBe(404);
153
+ expect(answer.headers().location, url).toBeUndefined();
154
+ const opened = await page.goto(url);
155
+ expect(opened?.status(), url).toBe(404);
156
+ expect(new URL(page.url()).origin, url).toBe(new URL(url).origin);
157
+ }
158
+ },
159
+ );
160
+
132
161
  define(
133
162
  "served.entry.code-once",
134
163
  "an entry code works once: replayed, it opens nothing",
135
164
  async ({ page }) => {
136
165
  await host.signIn(page);
137
- const code = await codeFor(page, host.url("entry-once"));
166
+ const code = await codeFor(page, await host.url("entry-once"));
138
167
  const request = page.context().request;
139
168
  const top = { "sec-fetch-dest": "document" };
140
169
  const first = await request.get(code, { maxRedirects: 0, headers: top });
@@ -150,8 +179,8 @@ export function entryTests(
150
179
  "an entry code taken to another instance's origin opens nothing there",
151
180
  async ({ page }) => {
152
181
  await host.signIn(page);
153
- const code = new URL(await codeFor(page, host.url("entry-mine")));
154
- const other = new URL(host.url("entry-theirs"));
182
+ const code = new URL(await codeFor(page, await host.url("entry-mine")));
183
+ const other = new URL(await host.url("entry-theirs"));
155
184
  const request = page.context().request;
156
185
  const answer = await request.get(`${other.origin}${code.pathname}${code.search}`, {
157
186
  maxRedirects: 0,
@@ -167,7 +196,7 @@ export function entryTests(
167
196
  "a clean address in a frame shows the content where the browser keeps the cookies, else asks for a tab: never the app's sign-in",
168
197
  async ({ page, browserName }, info) => {
169
198
  await host.signIn(page);
170
- const shown = await host.frame(page, host.url("entry-framed"));
199
+ const shown = await host.frame(page, await host.url("entry-framed"));
171
200
  const outcome = await shown.outcome();
172
201
  const frame = await shown.frame();
173
202
  expect(await host.atSignIn(page)).toBe(false);
@@ -194,7 +223,7 @@ export function entryTests(
194
223
  await host.signIn(page);
195
224
  // The app's page stays; the browser forgets every sign-in.
196
225
  await page.context().clearCookies();
197
- const shown = await host.frame(page, host.url("entry-framed-out"));
226
+ const shown = await host.frame(page, await host.url("entry-framed-out"));
198
227
  expect(await shown.outcome()).toMatchObject({ state: "blocked", reason: "sign-in" });
199
228
  const frame = await shown.frame();
200
229
  await expect(frame.locator("h1")).toHaveText("Open this in a new tab to sign in");
@@ -1,13 +1,14 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { fileURLToPath } from "node:url";
3
- import { expect, type Frame, type Page, type TestType } from "@playwright/test";
3
+ import { runInThisContext } from "node:vm";
4
+ import { expect, type Frame, type Page, type TestInfo, type TestType } from "@playwright/test";
4
5
  import type { PortholeConfig } from "../config/schema.ts";
5
6
  import type { FrameLocation, InstanceRef } from "../isolation/types.ts";
6
7
  import { entryTests, type EntryConformanceHost } from "./entry.ts";
7
8
  import type { ConformanceHarness } from "./harness/page.ts";
8
9
  import type { FrameOutcome } from "../host/types.ts";
9
10
  import type { ConformanceHost, LaunchedDisplay, LaunchSpec } from "./index.ts";
10
- import { leakTests, type ConformanceFixtures } from "./probes.ts";
11
+ import { leakTests, type ConformanceFixtures, type ConformanceTest } from "./probes.ts";
11
12
  import { servedTests, type ServedConformanceHost, type ServedDisplay } from "./served.ts";
12
13
 
13
14
  /**
@@ -55,14 +56,19 @@ export interface HostConformanceOptions {
55
56
  * signed in and out, and in a frame.
56
57
  */
57
58
  readonly entry?: {
58
- /** The clean address of `instances.artifact(scope, "1")` (`ContentHost.urlOf`). */
59
- readonly url: (scope: string) => string;
59
+ /**
60
+ * The clean address of `instances.artifact(scope, "1")` (`ContentHost.urlOf`); may publish it
61
+ * on demand.
62
+ */
63
+ readonly url: (scope: string) => string | Promise<string>;
60
64
  /** A clean address the signed-in viewer may not open. */
61
- readonly denied: (scope: string) => string;
65
+ readonly denied: (scope: string) => string | Promise<string>;
62
66
  /** Whether `page` shows the app's own sign-in. */
63
67
  readonly atSignIn: (page: Page) => Promise<boolean>;
64
68
  /** On the app's sign-in page: signs in, as a person does. */
65
69
  readonly completeSignIn: (page: Page) => Promise<void>;
70
+ /** Addresses on the content domain that name nothing: each a 404 there (P070). */
71
+ readonly unknown?: () => readonly string[] | Promise<readonly string[]>;
66
72
  };
67
73
  /** Case ids to skip, each with its reason (shown in the report). */
68
74
  readonly skip?: Readonly<Record<string, string>>;
@@ -338,9 +344,10 @@ export function hostConformance(options: HostConformanceOptions): HostConformanc
338
344
  denied: given.denied,
339
345
  atSignIn: given.atSignIn,
340
346
  completeSignIn: given.completeSignIn,
347
+ ...(given.unknown === undefined ? {} : { unknown: given.unknown }),
341
348
  async frame(page, url) {
342
349
  await ready(page);
343
- const instance = served.artifact("entry", "1");
350
+ const instance = await served.artifact("entry", "1");
344
351
  const id = await invoke(page, "show", options.config(instance), instance, {
345
352
  location: { url, origin: new URL(url).origin },
346
353
  });
@@ -435,32 +442,68 @@ export function defineHostConformance(
435
442
  if (options.instances.proxy === undefined) {
436
443
  absent(PROXY_CASES, "the app serves no MCP Apps views");
437
444
  }
438
- typed.describe(`Porthole leak suite (${options.name})`, () => {
439
- for (const each of leakTests(hosts.leak, { skip })) {
440
- // Playwright reads the fixtures a test uses from its parameter's destructuring.
441
- typed(each.title, async ({ page, browserName }, info) => {
442
- await hosts.begin(page);
443
- await each.run({ page, browserName }, info);
444
- });
445
- }
446
- });
447
- typed.describe(`Porthole served modes (${options.name})`, () => {
448
- for (const each of servedTests(hosts.served, { skip })) {
449
- typed(each.title, async ({ page, browserName }, info) => {
450
- await hosts.begin(page);
451
- await each.run({ page, browserName }, info);
452
- });
453
- }
454
- });
455
- const entry = hosts.entry;
456
- if (entry !== null) {
457
- typed.describe(`Porthole redirect entry (${options.name})`, () => {
458
- for (const each of entryTests(entry, { skip })) {
459
- typed(each.title, async ({ page, browserName }, info) => {
445
+ // Registered as the app's own spec file's (P070): Playwright locates a test where `test()` is
446
+ // called, so `playwright test <spec>` and `<spec>:<line>` select these.
447
+ const at = callerOf(defineHostConformance);
448
+ const describeOf = Reflect.get(test, "describe") as unknown as Register;
449
+ const describe = calledFrom(describeOf, at);
450
+ const register = calledFrom(typed as unknown as Register, at);
451
+ const suite = (title: string, tests: readonly ConformanceTest[]) => {
452
+ describe(title, () => {
453
+ for (const each of tests) {
454
+ // Playwright reads the fixtures a test uses from its parameter's destructuring.
455
+ register(each.title, async ({ page, browserName }: ConformanceFixtures, info: TestInfo) => {
460
456
  await hosts.begin(page);
461
457
  await each.run({ page, browserName }, info);
462
458
  });
463
459
  }
464
460
  });
461
+ };
462
+ suite(`Porthole leak suite (${options.name})`, leakTests(hosts.leak, { skip }));
463
+ suite(`Porthole served modes (${options.name})`, servedTests(hosts.served, { skip }));
464
+ if (hosts.entry !== null) {
465
+ suite(`Porthole redirect entry (${options.name})`, entryTests(hosts.entry, { skip }));
466
+ }
467
+ }
468
+
469
+ /** Where a function was called from: the caller's file and line, or null. */
470
+ function callerOf(callee: (...args: never[]) => unknown): { file: string; line: number } | null {
471
+ const holder: { stack?: string } = {};
472
+ Error.captureStackTrace(holder, callee);
473
+ // The first frame is the caller's: " at file:///…/app.spec.ts:16:1" or " at fn (/…/app.spec.ts:16:1)".
474
+ const frame = (holder.stack ?? "")
475
+ .split("\n")
476
+ .map((line) => line.trim())
477
+ .find((line) => line.startsWith("at "));
478
+ const where = (frame ?? "").slice(3).replace(/^.*\((.*)\)$/u, "$1");
479
+ const found = /^(.+):(\d+):\d+$/u.exec(where);
480
+ if (found?.[1] === undefined || found[2] === undefined) {
481
+ return null;
482
+ }
483
+ try {
484
+ return {
485
+ file: found[1].startsWith("file://") ? fileURLToPath(found[1]) : found[1],
486
+ line: Number(found[2]),
487
+ };
488
+ } catch {
489
+ return null;
490
+ }
491
+ }
492
+
493
+ type Register = (title: string, body: unknown) => void;
494
+
495
+ /**
496
+ * `register` (Playwright's `test` or `test.describe`), called from a function compiled as the
497
+ * caller's file and line (`vm`'s `filename` and `lineOffset`), so the tests it makes are located
498
+ * there. Without a caller, `register` itself.
499
+ */
500
+ function calledFrom(register: Register, at: { file: string; line: number } | null): Register {
501
+ if (at === null) {
502
+ return (title, body) => register(title, body);
465
503
  }
504
+ const call = runInThisContext("(register, title, body) => register(title, body)", {
505
+ filename: at.file,
506
+ lineOffset: Math.max(0, at.line - 1),
507
+ }) as (register: Register, title: string, body: unknown) => void;
508
+ return (title, body) => call(register, title, body);
466
509
  }