@botiverse/porthole 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/conformance/clipboard.js +5 -2
- package/dist/conformance/entry.d.ts +37 -0
- package/dist/conformance/entry.js +120 -0
- package/dist/conformance/harness/page.d.ts +32 -0
- package/dist/conformance/harness/page.js +215 -0
- package/dist/conformance/harness.js +1662 -0
- package/dist/conformance/host-kit.d.ts +85 -0
- package/dist/conformance/host-kit.js +341 -0
- package/dist/conformance/index.d.ts +2 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/limits.js +16 -1
- package/dist/conformance/served.js +13 -3
- package/dist/guide/limits.js +9 -1
- package/dist/host/locator.d.ts +39 -0
- package/dist/host/locator.js +72 -0
- package/dist/host/outcome.js +1 -0
- package/dist/host/types.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node/index.d.ts +41 -0
- package/dist/node/index.js +160 -0
- package/dist/platform/web.js +12 -1
- package/dist/server/domain.d.ts +43 -0
- package/dist/server/domain.js +100 -0
- package/dist/server/host.d.ts +158 -0
- package/dist/server/host.js +416 -0
- package/dist/server/index.d.ts +9 -2
- package/dist/server/index.js +7 -0
- package/dist/server/naming.d.ts +12 -1
- package/dist/server/naming.js +9 -8
- package/dist/server/pages.d.ts +9 -0
- package/dist/server/pages.js +32 -0
- package/dist/server/server.js +287 -50
- package/dist/server/types.d.ts +37 -0
- package/dist/transport/post-message.d.ts +9 -1
- package/dist/transport/post-message.js +11 -2
- package/package.json +6 -2
- package/src/conformance/clipboard.ts +5 -2
- package/src/conformance/entry.ts +206 -0
- package/src/conformance/harness/page.ts +267 -0
- package/src/conformance/host-kit.ts +466 -0
- package/src/conformance/index.ts +12 -0
- package/src/conformance/limits.ts +21 -1
- package/src/conformance/served.ts +14 -3
- package/src/guide/limits.ts +9 -1
- package/src/host/locator.ts +111 -0
- package/src/host/outcome.ts +1 -0
- package/src/host/types.ts +2 -0
- package/src/index.ts +7 -0
- package/src/node/index.ts +221 -0
- package/src/platform/web.ts +19 -1
- package/src/server/domain.ts +158 -0
- package/src/server/host.ts +706 -0
- package/src/server/index.ts +27 -1
- package/src/server/naming.ts +22 -2
- package/src/server/pages.ts +44 -0
- package/src/server/server.ts +355 -63
- package/src/server/types.ts +33 -0
- package/src/transport/post-message.ts +12 -1
|
@@ -8,14 +8,17 @@ import { settle } from "./oracles.js";
|
|
|
8
8
|
* from the host's page, then pasted back into a text area there.
|
|
9
9
|
*/
|
|
10
10
|
const SEED = "porthole-seed";
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* A text area of the oracle's own on the host's page: in the conformance harness's layer where
|
|
13
|
+
* there is one (over an app's own page, P066), else at the top of the page.
|
|
14
|
+
*/
|
|
12
15
|
async function area(page, id) {
|
|
13
16
|
await page.evaluate((each) => {
|
|
14
17
|
if (document.getElementById(each) === null) {
|
|
15
18
|
const element = document.createElement("textarea");
|
|
16
19
|
element.id = each;
|
|
17
20
|
element.style.cssText = "display:block;width:200px;height:40px";
|
|
18
|
-
document.body.prepend(element);
|
|
21
|
+
(document.getElementById("porthole-conformance") ?? document.body).prepend(element);
|
|
19
22
|
}
|
|
20
23
|
}, id);
|
|
21
24
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Frame, type Page } from "@playwright/test";
|
|
2
|
+
import type { FrameOutcome } from "../host/types.ts";
|
|
3
|
+
import type { ConformanceTest } from "./probes.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Redirect entry's cases (P068): a clean address opens directly, signed in or not, and never
|
|
6
|
+
* shows content without access; its one-time code works once, on its own origin; in a frame, the
|
|
7
|
+
* app's sign-in never shows. A host implements `EntryConformanceHost` (or gives
|
|
8
|
+
* `defineHostConformance` its `entry`).
|
|
9
|
+
*/
|
|
10
|
+
export interface EntryConformanceHost {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
/** Signs `page`'s browser in at the app, as a person does, leaving it on the app's page. */
|
|
13
|
+
signIn(page: Page): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* The clean address of a stored page the signed-in viewer may open (`ContentHost.urlOf`), for
|
|
16
|
+
* `scope`: it draws `<main id="artifact">version 1</main>`.
|
|
17
|
+
*/
|
|
18
|
+
url(scope: string): string;
|
|
19
|
+
/** A clean address the signed-in viewer may not open. */
|
|
20
|
+
denied(scope: string): string;
|
|
21
|
+
/** Whether `page` shows the app's own sign-in. */
|
|
22
|
+
atSignIn(page: Page): Promise<boolean>;
|
|
23
|
+
/** On the app's sign-in page: signs in, as a person does (and the app goes back). */
|
|
24
|
+
completeSignIn(page: Page): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Frames a clean address on the app's page as the app would (a location of that address),
|
|
27
|
+
* returning the frame and the display's outcome once it has one.
|
|
28
|
+
*/
|
|
29
|
+
frame(page: Page, url: string): Promise<{
|
|
30
|
+
frame(): Promise<Frame>;
|
|
31
|
+
outcome(): Promise<FrameOutcome>;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
export interface EntrySuiteOptions {
|
|
35
|
+
readonly skip?: Readonly<Record<string, string>>;
|
|
36
|
+
}
|
|
37
|
+
export declare function entryTests(host: EntryConformanceHost, options?: EntrySuiteOptions): ConformanceTest[];
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { expect } from "@playwright/test";
|
|
2
|
+
/** Whether a page shows the stored page's own content. */
|
|
3
|
+
const shows = async (where) => (await where.locator("#artifact").count()) > 0;
|
|
4
|
+
/** Walks a redirect entry by hand, as the browser would on a tab: the code URL it ends with. */
|
|
5
|
+
async function codeFor(page, url) {
|
|
6
|
+
const request = page.context().request;
|
|
7
|
+
const top = { "sec-fetch-dest": "document" };
|
|
8
|
+
const begin = await request.get(url, { maxRedirects: 0, headers: top });
|
|
9
|
+
expect(begin.status(), "a clean address without a cookie goes to the app").toBe(302);
|
|
10
|
+
const authorized = await request.get(begin.headers().location ?? "", {
|
|
11
|
+
maxRedirects: 0,
|
|
12
|
+
headers: top,
|
|
13
|
+
});
|
|
14
|
+
expect(authorized.status(), "the app's authorize route gives a code").toBe(302);
|
|
15
|
+
const code = authorized.headers().location ?? "";
|
|
16
|
+
expect(code).toContain("/__porthole/entry?code=");
|
|
17
|
+
return code;
|
|
18
|
+
}
|
|
19
|
+
export function entryTests(host, options = {}) {
|
|
20
|
+
const tests = [];
|
|
21
|
+
const define = (id, title, body) => {
|
|
22
|
+
tests.push({
|
|
23
|
+
id,
|
|
24
|
+
title: `${id}: ${title}`,
|
|
25
|
+
async run({ page, browserName }, info) {
|
|
26
|
+
const reason = options.skip?.[id];
|
|
27
|
+
info.skip(reason !== undefined, reason);
|
|
28
|
+
await body({ page, browserName }, info);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
define("served.entry.direct", "a clean address opens directly for a signed-in viewer, and stays clean; a reload rides on its cookie", async ({ page }) => {
|
|
33
|
+
await host.signIn(page);
|
|
34
|
+
const url = host.url("entry-direct");
|
|
35
|
+
await page.goto(url);
|
|
36
|
+
await expect(page.locator("#artifact")).toHaveText("version 1");
|
|
37
|
+
expect(page.url(), "the address bar shows the clean address, no code").toBe(url);
|
|
38
|
+
const entries = [];
|
|
39
|
+
page.on("request", (request) => {
|
|
40
|
+
if (request.url().includes("/__porthole/entry")) {
|
|
41
|
+
entries.push(request.url());
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
await page.reload();
|
|
45
|
+
await expect(page.locator("#artifact")).toHaveText("version 1");
|
|
46
|
+
expect(entries, "a reload needs no entry").toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
define("served.entry.signed-out", "signed out, a clean address goes to the app's own sign-in, then back to the content", async ({ page }) => {
|
|
49
|
+
const url = host.url("entry-signed-out");
|
|
50
|
+
await page.goto(url);
|
|
51
|
+
expect(await host.atSignIn(page), "the app's sign-in").toBe(true);
|
|
52
|
+
expect(await shows(page)).toBe(false);
|
|
53
|
+
await host.completeSignIn(page);
|
|
54
|
+
await expect(page.locator("#artifact")).toHaveText("version 1");
|
|
55
|
+
expect(page.url()).toBe(url);
|
|
56
|
+
});
|
|
57
|
+
define("served.entry.denied", "a viewer without access gets the app's refusal, never the content", async ({ page }) => {
|
|
58
|
+
await host.signIn(page);
|
|
59
|
+
const url = host.denied("entry-denied");
|
|
60
|
+
const answer = await page.goto(url);
|
|
61
|
+
expect(answer?.status()).toBe(403);
|
|
62
|
+
expect(await shows(page)).toBe(false);
|
|
63
|
+
expect(new URL(page.url()).origin, "the refusal is the app's").not.toBe(new URL(url).origin);
|
|
64
|
+
});
|
|
65
|
+
define("served.entry.code-once", "an entry code works once: replayed, it opens nothing", async ({ page }) => {
|
|
66
|
+
await host.signIn(page);
|
|
67
|
+
const code = await codeFor(page, host.url("entry-once"));
|
|
68
|
+
const request = page.context().request;
|
|
69
|
+
const top = { "sec-fetch-dest": "document" };
|
|
70
|
+
const first = await request.get(code, { maxRedirects: 0, headers: top });
|
|
71
|
+
expect(first.status()).toBe(303);
|
|
72
|
+
const again = await request.get(code, { maxRedirects: 0, headers: top });
|
|
73
|
+
expect(again.status()).toBe(403);
|
|
74
|
+
expect(await again.text()).not.toContain('id="artifact"');
|
|
75
|
+
});
|
|
76
|
+
define("served.entry.code-elsewhere", "an entry code taken to another instance's origin opens nothing there", async ({ page }) => {
|
|
77
|
+
await host.signIn(page);
|
|
78
|
+
const code = new URL(await codeFor(page, host.url("entry-mine")));
|
|
79
|
+
const other = new URL(host.url("entry-theirs"));
|
|
80
|
+
const request = page.context().request;
|
|
81
|
+
const answer = await request.get(`${other.origin}${code.pathname}${code.search}`, {
|
|
82
|
+
maxRedirects: 0,
|
|
83
|
+
headers: { "sec-fetch-dest": "document" },
|
|
84
|
+
});
|
|
85
|
+
expect(answer.status()).toBe(403);
|
|
86
|
+
expect(answer.headers()["set-cookie"] ?? "").not.toMatch(/porthole-[a-z2-7]{16}=/u);
|
|
87
|
+
});
|
|
88
|
+
define("served.entry.framed", "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", async ({ page, browserName }, info) => {
|
|
89
|
+
await host.signIn(page);
|
|
90
|
+
const shown = await host.frame(page, host.url("entry-framed"));
|
|
91
|
+
const outcome = await shown.outcome();
|
|
92
|
+
const frame = await shown.frame();
|
|
93
|
+
expect(await host.atSignIn(page)).toBe(false);
|
|
94
|
+
expect(new URL(frame.url()).pathname).not.toMatch(/sign-?in/iu);
|
|
95
|
+
if (outcome.state === "loaded") {
|
|
96
|
+
await expect(frame.locator("#artifact")).toHaveText("version 1");
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
expect(outcome).toMatchObject({ state: "blocked" });
|
|
100
|
+
expect(["cookies", "sign-in"]).toContain(outcome.reason);
|
|
101
|
+
expect(await shows(frame)).toBe(false);
|
|
102
|
+
}
|
|
103
|
+
const said = outcome.state === "blocked" ? `blocked: ${outcome.reason}` : outcome.state;
|
|
104
|
+
info.annotations.push({
|
|
105
|
+
type: "measured",
|
|
106
|
+
description: `${browserName}: framed entry ${said}`,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
define("served.entry.framed-signed-out", "signed out, a clean address in a frame asks for a tab to sign in: the sign-in never shows in a frame", async ({ page }) => {
|
|
110
|
+
await host.signIn(page);
|
|
111
|
+
// The app's page stays; the browser forgets every sign-in.
|
|
112
|
+
await page.context().clearCookies();
|
|
113
|
+
const shown = await host.frame(page, host.url("entry-framed-out"));
|
|
114
|
+
expect(await shown.outcome()).toMatchObject({ state: "blocked", reason: "sign-in" });
|
|
115
|
+
const frame = await shown.frame();
|
|
116
|
+
await expect(frame.locator("h1")).toHaveText("Open this in a new tab to sign in");
|
|
117
|
+
expect(await shows(frame)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
return tests;
|
|
120
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ConfirmRequest } from "../../bridge/types.ts";
|
|
2
|
+
import type { PortholeConfig } from "../../config/schema.ts";
|
|
3
|
+
import type { FrameLocation, InstanceRef } from "../../isolation/types.ts";
|
|
4
|
+
import type { LaunchSpec, RecordedCall, RecordedEvent } from "../index.ts";
|
|
5
|
+
declare const harness: {
|
|
6
|
+
/** An opaque display with the suite's content (the leak suite's `launch`). */
|
|
7
|
+
launch(spec: LaunchSpec): Promise<number>;
|
|
8
|
+
/**
|
|
9
|
+
* A served display, located by the app's issue endpoint (`issue`, from this page with its own
|
|
10
|
+
* cookies) or at a location the test got (`location`), with the suite's content for a shell.
|
|
11
|
+
*/
|
|
12
|
+
show(config: PortholeConfig, instance: InstanceRef, where: {
|
|
13
|
+
readonly issue: string;
|
|
14
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
15
|
+
} | {
|
|
16
|
+
readonly location: FrameLocation;
|
|
17
|
+
}, content?: string): number;
|
|
18
|
+
origin: (id: number) => string | null;
|
|
19
|
+
failure: (id: number) => string | null;
|
|
20
|
+
received: (id: number) => unknown[];
|
|
21
|
+
post(id: number, message: unknown): void;
|
|
22
|
+
setContent(id: number, html: string, partial: boolean): void;
|
|
23
|
+
calls: (id: number) => RecordedCall[];
|
|
24
|
+
events: (id: number) => RecordedEvent[];
|
|
25
|
+
errors: (id: number) => string[];
|
|
26
|
+
pendingConfirmations: (id: number) => number;
|
|
27
|
+
answer(id: number, accept: boolean): ConfirmRequest | null;
|
|
28
|
+
setTheme: (id: number, scheme: "light" | "dark") => void | undefined;
|
|
29
|
+
destroy(id: number): void;
|
|
30
|
+
};
|
|
31
|
+
export type ConformanceHarness = typeof harness;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createHost } from "../../host/create-host.js";
|
|
2
|
+
import { httpLocator } from "../../host/locator.js";
|
|
3
|
+
const EVENTS = [
|
|
4
|
+
"initialized",
|
|
5
|
+
"resize",
|
|
6
|
+
"error",
|
|
7
|
+
"contentError",
|
|
8
|
+
"hostError",
|
|
9
|
+
"navigated",
|
|
10
|
+
"refused",
|
|
11
|
+
"muted",
|
|
12
|
+
"outcome",
|
|
13
|
+
"annotation",
|
|
14
|
+
];
|
|
15
|
+
const nonce = document.currentScript instanceof HTMLScriptElement && document.currentScript.nonce !== ""
|
|
16
|
+
? document.currentScript.nonce
|
|
17
|
+
: undefined;
|
|
18
|
+
const displays = new Map();
|
|
19
|
+
let next = 0;
|
|
20
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
/** The harness's own layer, over the app's page, so the app's UI never takes a click. */
|
|
22
|
+
function layer() {
|
|
23
|
+
let element = document.getElementById("porthole-conformance");
|
|
24
|
+
if (element === null) {
|
|
25
|
+
element = document.createElement("main");
|
|
26
|
+
element.id = "porthole-conformance";
|
|
27
|
+
element.style.cssText =
|
|
28
|
+
"position:fixed;inset:0;overflow:auto;z-index:2147483000;background:#fff;color:#000;padding:8px";
|
|
29
|
+
document.body.append(element);
|
|
30
|
+
}
|
|
31
|
+
return element;
|
|
32
|
+
}
|
|
33
|
+
function box(id, widthPx, heightPx) {
|
|
34
|
+
const element = document.createElement("section");
|
|
35
|
+
element.id = `porthole-display-${id}`;
|
|
36
|
+
element.style.width = `${widthPx}px`;
|
|
37
|
+
if (heightPx !== undefined) {
|
|
38
|
+
element.style.height = `${heightPx}px`;
|
|
39
|
+
}
|
|
40
|
+
layer().append(element);
|
|
41
|
+
return element;
|
|
42
|
+
}
|
|
43
|
+
function recorder(calls) {
|
|
44
|
+
const record = (capability, value) => (args) => {
|
|
45
|
+
calls.push({ capability, args });
|
|
46
|
+
return value;
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
sendPrompt: record("sendPrompt", true),
|
|
50
|
+
openLink: record("openLink", true),
|
|
51
|
+
downloadFile: record("downloadFile", true),
|
|
52
|
+
callTool: record("callTool", { content: [] }),
|
|
53
|
+
readData: record("readData", { contents: [] }),
|
|
54
|
+
setModelContext: record("setModelContext", undefined),
|
|
55
|
+
copyText: record("copyText", true),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function watch(id, shown) {
|
|
59
|
+
for (const type of EVENTS) {
|
|
60
|
+
shown.handle.on(type, (detail) => shown.events.push({ type, detail }));
|
|
61
|
+
}
|
|
62
|
+
displays.set(id, shown);
|
|
63
|
+
}
|
|
64
|
+
/** The frame's messages, from its window and its located origin only, as a host takes them. */
|
|
65
|
+
addEventListener("message", (event) => {
|
|
66
|
+
for (const shown of displays.values()) {
|
|
67
|
+
if (shown.frame?.contentWindow === event.source && event.origin === shown.origin) {
|
|
68
|
+
shown.received.push(event.data);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const harness = {
|
|
73
|
+
/** An opaque display with the suite's content (the leak suite's `launch`). */
|
|
74
|
+
async launch(spec) {
|
|
75
|
+
const id = next;
|
|
76
|
+
next += 1;
|
|
77
|
+
const container = box(id, spec.widthPx ?? 600, spec.heightPx);
|
|
78
|
+
const calls = [];
|
|
79
|
+
const confirmations = [];
|
|
80
|
+
const host = createHost(spec.config, {
|
|
81
|
+
handlers: recorder(calls),
|
|
82
|
+
...(spec.confirms === false
|
|
83
|
+
? {}
|
|
84
|
+
: {
|
|
85
|
+
confirm: (request) => new Promise((resolve) => confirmations.push({ request, resolve })),
|
|
86
|
+
}),
|
|
87
|
+
theme: (scheme) => spec.hostTheme?.[scheme] ?? {},
|
|
88
|
+
hostInfo: { name: "porthole-conformance", version: "1" },
|
|
89
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
90
|
+
});
|
|
91
|
+
const handle = host.mount(container, {
|
|
92
|
+
displayId: `display-${id}`,
|
|
93
|
+
title: spec.title ?? "Probe",
|
|
94
|
+
scheme: spec.scheme ?? "light",
|
|
95
|
+
...(spec.document === undefined ? {} : { document: spec.document }),
|
|
96
|
+
});
|
|
97
|
+
const shown = {
|
|
98
|
+
handle,
|
|
99
|
+
calls,
|
|
100
|
+
events: [],
|
|
101
|
+
confirmations,
|
|
102
|
+
received: [],
|
|
103
|
+
origin: null,
|
|
104
|
+
failure: null,
|
|
105
|
+
frame: null,
|
|
106
|
+
};
|
|
107
|
+
const initialized = new Promise((resolve) => handle.on("initialized", () => resolve()));
|
|
108
|
+
watch(id, shown);
|
|
109
|
+
if (spec.document === undefined) {
|
|
110
|
+
await Promise.race([initialized, pause(5_000)]);
|
|
111
|
+
for (const partial of spec.partials ?? []) {
|
|
112
|
+
handle.setContent(partial, { partial: true });
|
|
113
|
+
await pause(150);
|
|
114
|
+
}
|
|
115
|
+
if (spec.content !== undefined) {
|
|
116
|
+
handle.setContent(spec.content);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return id;
|
|
120
|
+
},
|
|
121
|
+
/**
|
|
122
|
+
* A served display, located by the app's issue endpoint (`issue`, from this page with its own
|
|
123
|
+
* cookies) or at a location the test got (`location`), with the suite's content for a shell.
|
|
124
|
+
*/
|
|
125
|
+
show(config, instance, where, content) {
|
|
126
|
+
const id = next;
|
|
127
|
+
next += 1;
|
|
128
|
+
const container = box(id, 600, 400);
|
|
129
|
+
const calls = [];
|
|
130
|
+
const shown = {
|
|
131
|
+
handle: null,
|
|
132
|
+
calls,
|
|
133
|
+
events: [],
|
|
134
|
+
confirmations: [],
|
|
135
|
+
received: [],
|
|
136
|
+
origin: null,
|
|
137
|
+
failure: null,
|
|
138
|
+
frame: null,
|
|
139
|
+
};
|
|
140
|
+
const asked = "issue" in where
|
|
141
|
+
? httpLocator(where.issue, where.headers === undefined ? {} : { headers: where.headers })
|
|
142
|
+
: { locate: () => Promise.resolve(where.location) };
|
|
143
|
+
const locator = {
|
|
144
|
+
async locate(each, signal) {
|
|
145
|
+
try {
|
|
146
|
+
const location = await asked.locate(each, signal);
|
|
147
|
+
shown.origin = location.origin;
|
|
148
|
+
return location;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
shown.failure =
|
|
152
|
+
error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
const handle = createHost(config, {
|
|
158
|
+
handlers: recorder(calls),
|
|
159
|
+
locator,
|
|
160
|
+
hostInfo: { name: "porthole-conformance", version: "1" },
|
|
161
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
162
|
+
}).mount(container, {
|
|
163
|
+
displayId: `display-${id}`,
|
|
164
|
+
title: "Probe",
|
|
165
|
+
scheme: "light",
|
|
166
|
+
instance,
|
|
167
|
+
});
|
|
168
|
+
shown.handle = handle;
|
|
169
|
+
watch(id, shown);
|
|
170
|
+
// The frame element, once it exists, so its messages can be told from others'.
|
|
171
|
+
const found = () => {
|
|
172
|
+
shown.frame = container.querySelector("iframe");
|
|
173
|
+
if (shown.frame === null) {
|
|
174
|
+
requestAnimationFrame(found);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
found();
|
|
178
|
+
if (content !== undefined) {
|
|
179
|
+
handle.setContent(content);
|
|
180
|
+
}
|
|
181
|
+
return id;
|
|
182
|
+
},
|
|
183
|
+
origin: (id) => displays.get(id)?.origin ?? null,
|
|
184
|
+
failure: (id) => displays.get(id)?.failure ?? null,
|
|
185
|
+
received: (id) => displays.get(id)?.received ?? [],
|
|
186
|
+
post(id, message) {
|
|
187
|
+
const shown = displays.get(id);
|
|
188
|
+
if (shown?.frame?.contentWindow && shown.origin !== null) {
|
|
189
|
+
shown.frame.contentWindow.postMessage(message, shown.origin);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
setContent(id, html, partial) {
|
|
193
|
+
displays.get(id)?.handle.setContent(html, { partial });
|
|
194
|
+
},
|
|
195
|
+
calls: (id) => displays.get(id)?.calls ?? [],
|
|
196
|
+
events: (id) => displays.get(id)?.events ?? [],
|
|
197
|
+
errors: (id) => (displays.get(id)?.events ?? []).flatMap((event) => event.type === "error" ? [event.detail.message] : []),
|
|
198
|
+
pendingConfirmations: (id) => displays.get(id)?.confirmations.length ?? 0,
|
|
199
|
+
answer(id, accept) {
|
|
200
|
+
const waiting = displays.get(id)?.confirmations.shift();
|
|
201
|
+
if (waiting === undefined) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
waiting.resolve(accept);
|
|
205
|
+
return waiting.request;
|
|
206
|
+
},
|
|
207
|
+
setTheme: (id, scheme) => displays.get(id)?.handle.setTheme(scheme),
|
|
208
|
+
destroy(id) {
|
|
209
|
+
displays.get(id)?.handle.destroy();
|
|
210
|
+
document.getElementById(`porthole-display-${id}`)?.remove();
|
|
211
|
+
displays.delete(id);
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
window.portholeConformance = harness;
|
|
215
|
+
document.documentElement.dataset.portholeConformance = "ready";
|