Skip to content

Commit 6cf9fbd

Browse files
committed
fix(playground): move iframe host integration
1 parent 09c14cd commit 6cf9fbd

11 files changed

Lines changed: 189 additions & 39 deletions

File tree

playground/src/components/DiagnosisView.tsx

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,17 @@ export function DiagnosisView({
7272
};
7373
}, [services, testResults]);
7474

75+
const reportMarkdown = useMemo(
76+
() =>
77+
hasResults && !isRunning
78+
? renderReportMarkdown(services, testResults)
79+
: "",
80+
[hasResults, isRunning, services, testResults],
81+
);
82+
7583
const handleCopyReport = async () => {
7684
try {
77-
// Rendered on demand: the full report is only needed on copy, not on
78-
// every per-method result update during a run.
79-
await navigator.clipboard.writeText(
80-
renderReportMarkdown(services, testResults),
81-
);
85+
await navigator.clipboard.writeText(reportMarkdown);
8286
setCopied(true);
8387
setTimeout(() => setCopied(false), 1500);
8488
} catch {
@@ -92,7 +96,7 @@ export function DiagnosisView({
9296
// Copy the report to the clipboard first as a fallback if the body is
9397
// truncated.
9498
const handleSubmitReport = () => {
95-
const report = renderReportMarkdown(services, testResults);
99+
const report = reportMarkdown;
96100
void navigator.clipboard?.writeText(report).catch(() => {});
97101
const url = reportIssueUrl(report, detectHostMode());
98102
try {
@@ -123,11 +127,12 @@ export function DiagnosisView({
123127
<span className="panel__label">About</span>
124128
</div>
125129
<p className="panel__desc">
126-
Runs every TrUAPI method against the connected host to build a coverage
127-
report — which methods work, which fail, and which aren&apos;t wired
128-
yet. Methods run one at a time, in order; those that need your approval
129-
(signing, permission and resource requests) wait on your response
130-
before the run continues. When it finishes, copy the report below.
130+
Runs every TrUAPI method against the connected host to build a
131+
coverage report — which methods work, which fail, and which
132+
aren&apos;t wired yet. Methods run one at a time, in order; those that
133+
need your approval (signing, permission and resource requests) wait on
134+
your response before the run continues. When it finishes, copy the
135+
report below.
131136
</p>
132137
<p className="diag__callout">
133138
Before you start: make sure you are <strong>logged in</strong>, and
@@ -145,24 +150,38 @@ export function DiagnosisView({
145150
Stop
146151
</button>
147152
) : (
148-
<button type="button" className="btn btn--primary" onClick={onRun}>
153+
<button
154+
type="button"
155+
className="btn btn--primary"
156+
data-testid="diagnosis-run"
157+
onClick={onRun}
158+
>
149159
<span className="btn__glyph"></span>
150160
Run diagnosis
151161
</button>
152162
)}
153163
{hasResults && (
154164
<span
155165
className="autotest__summary"
166+
data-testid="diagnosis-summary"
156167
data-has-fail={!isRunning && failCount > 0}
157168
>
158169
{passCount} success · {failCount} failed
159170
</span>
160171
)}
161172
{hasResults && !isRunning && (
162173
<div className="diag__report-actions">
174+
<pre
175+
hidden
176+
data-testid="diagnosis-report-markdown"
177+
data-report-ready={reportMarkdown.length > 0}
178+
>
179+
{reportMarkdown}
180+
</pre>
163181
<button
164182
type="button"
165183
className="autotest__report-copy"
184+
data-testid="diagnosis-copy-report"
166185
onClick={handleCopyReport}
167186
>
168187
{copied ? "Copied ✓" : "Copy report"}
@@ -188,6 +207,7 @@ export function DiagnosisView({
188207
<div key={r.id}>
189208
<div
190209
className="diag__row"
210+
data-testid="diagnosis-row"
191211
data-status={r.status}
192212
data-expandable={expandable}
193213
onClick={

playground/src/components/ServiceTable.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function ServiceTable({
3030
<button
3131
type="button"
3232
className="method method--autotest"
33+
data-testid="diagnosis-entry"
3334
data-active={isDiagnosisActive}
3435
data-supported="true"
3536
onClick={() => onSelect(DIAGNOSIS_ID, "")}

playground/src/lib/auto-test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import {
2-
runExample,
3-
type LogEntry,
4-
type RunResult,
5-
} from "./example-runner";
1+
import { runExample, type LogEntry, type RunResult } from "./example-runner";
62
import { getClient } from "./transport";
73
import type { MethodInfo, ServiceInfo } from "./services";
84

@@ -18,6 +14,7 @@ export interface TestEntry {
1814

1915
const UNARY_TIMEOUT_MS = 10_000;
2016
const SIGNING_TIMEOUT_MS = 30_000;
17+
const SSO_TIMEOUT_MS = 60_000;
2118

2219
// Services skipped wholesale in the diagnosis until hosts wire them up.
2320
const SKIPPED_SERVICES = new Set(["Coin Payment"]);
@@ -36,6 +33,10 @@ const LONG_TIMEOUT_METHODS = new Set([
3633
"Preimage/submit",
3734
]);
3835

36+
const METHOD_TIMEOUT_MS = new Map<string, number>([
37+
["Account/get_account_alias", SSO_TIMEOUT_MS],
38+
]);
39+
3940
type RunOneOpts = {
4041
serviceName: string;
4142
method: MethodInfo;
@@ -63,9 +64,9 @@ async function runOne({
6364
const source = method.exampleSource;
6465
const logs: LogEntry[] = [];
6566
const onLog = (entry: LogEntry) => logs.push(entry);
66-
const timeoutMs = LONG_TIMEOUT_METHODS.has(id)
67-
? SIGNING_TIMEOUT_MS
68-
: UNARY_TIMEOUT_MS;
67+
const timeoutMs =
68+
METHOD_TIMEOUT_MS.get(id) ??
69+
(LONG_TIMEOUT_METHODS.has(id) ? SIGNING_TIMEOUT_MS : UNARY_TIMEOUT_MS);
6970

7071
// The example decides pass/fail explicitly: it resolves on success and throws
7172
// (via `assert(...)` or any uncaught error) on failure. `console.*` is pure

playground/src/lib/diagnosis-report.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,9 @@ export function renderReportMarkdown(
6666
return lines.join("\n");
6767
}
6868

69-
// The failure reason for a method, flattened to a single escaped table cell.
70-
// Only failures carry details; other statuses leave the cell empty.
69+
// Method output flattened to a single escaped table cell.
7170
function detailCell(entry: TestEntry | undefined): string {
72-
if (entry?.status !== "fail" || entry.output == null) return "";
71+
if (entry?.output == null) return "";
7372
return entry.output.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
7473
}
7574

playground/src/lib/example-runner.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,10 @@ export async function runExample(opts: {
7676
client: TrUApiClient;
7777
onLog: (entry: LogEntry) => void;
7878
}): Promise<RunResult> {
79-
const { source, client, onLog } = opts;
80-
79+
const { client, onLog } = opts;
8180
let js: string;
8281
try {
83-
js = transform(source, { transforms: ["typescript"] }).code;
82+
js = transform(opts.source, { transforms: ["typescript"] }).code;
8483
} catch (err) {
8584
throw new ExampleSyntaxError(
8685
err instanceof Error ? err.message : String(err),

playground/src/lib/transport.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
createClient,
3-
createIframeProvider,
43
createMessagePortProvider,
54
createTransport,
65
type Provider,
@@ -48,6 +47,61 @@ async function waitForWebviewPort(
4847
);
4948
}
5049

50+
function waitForIframePort(
51+
hostOrigin: string,
52+
signal?: AbortSignal,
53+
timeoutMs = 20_000,
54+
): Promise<MessagePort> {
55+
return new Promise((resolve, reject) => {
56+
let settled = false;
57+
let timer: ReturnType<typeof setTimeout> | null = null;
58+
59+
const cleanup = () => {
60+
window.removeEventListener("message", onMessage);
61+
signal?.removeEventListener("abort", onAbort);
62+
if (timer !== null) clearTimeout(timer);
63+
};
64+
const finish = (port: MessagePort) => {
65+
if (settled) return;
66+
settled = true;
67+
cleanup();
68+
resolve(port);
69+
};
70+
const fail = (error: Error) => {
71+
if (settled) return;
72+
settled = true;
73+
cleanup();
74+
reject(error);
75+
};
76+
const onAbort = () => fail(new Error("waitForIframePort aborted"));
77+
const onMessage = (event: MessageEvent) => {
78+
if (event.source !== window.parent) return;
79+
if (event.origin !== hostOrigin) return;
80+
if (event.data?.type !== "truapi-init") return;
81+
const port = event.ports[0];
82+
if (!port) {
83+
fail(new Error("truapi-init did not include a MessagePort"));
84+
return;
85+
}
86+
finish(port);
87+
};
88+
89+
window.addEventListener("message", onMessage);
90+
signal?.addEventListener("abort", onAbort, { once: true });
91+
timer = setTimeout(
92+
() =>
93+
fail(
94+
new Error(
95+
`Timed out waiting for iframe MessagePort (${timeoutMs}ms)`,
96+
),
97+
),
98+
timeoutMs,
99+
);
100+
101+
window.parent.postMessage({ type: "truapi-ready" }, hostOrigin);
102+
});
103+
}
104+
51105
/** Origin used as the `targetOrigin` argument for outbound `postMessage`
52106
* frames. `document.referrer` is the URL of the parent document that loaded
53107
* the iframe, so its origin is the host that's expected to receive our
@@ -76,7 +130,16 @@ function createSandboxProvider(): Provider {
76130
"the playground must be embedded by a host that sends a Referer header.",
77131
);
78132
}
79-
return createIframeProvider({ target: window.parent, hostOrigin });
133+
const portController = new AbortController();
134+
const provider = createMessagePortProvider(
135+
waitForIframePort(hostOrigin, portController.signal),
136+
);
137+
const baseDispose = provider.dispose;
138+
provider.dispose = () => {
139+
portController.abort();
140+
baseDispose?.();
141+
};
142+
return provider;
80143
}
81144
if (isWebview()) {
82145
const portController = new AbortController();

playground/tests/e2e/handshake.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ test.describe("handshake", () => {
1111
await waitForOnline(frame);
1212

1313
// Once connected, the splash unmounts and the service rail mounts.
14-
// The Auto-Test entry button is the simplest stable proof of that.
15-
await expect(frame.locator(".method--autotest")).toBeVisible();
14+
// The diagnosis entry is the simplest stable proof of that.
15+
await expect(
16+
frame.getByRole("button", { name: /Diagnosis Full host coverage report/ }),
17+
).toBeVisible();
1618
});
1719
});

playground/tests/e2e/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export async function openPlaygroundInDotli(page: Page): Promise<FrameLocator> {
1212
// dotli renders an additional hidden iframe (host.localhost:5173?mode=direct)
1313
// alongside the proxied playground; scope to the playground src so the
1414
// FrameLocator is unique under Playwright strict mode.
15-
const frame = page.frameLocator('iframe[src="http://localhost:3000"]');
15+
const frame = page.frameLocator('iframe[src^="http://localhost:3000"]');
1616
// The playground renders the masthead once mounted; the status chip is
1717
// there from the first render in either splash or shell mode.
1818
await expect(frame.locator(".status")).toBeVisible({ timeout: 30_000 });
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
import { openPlaygroundInDotli, waitForOnline } from "./helpers";
3+
4+
/**
5+
* The login pairing UI is driven by the Rust core's ordered auth-state
6+
* stream. This spec pins the two failure modes of the event-soup era:
7+
* a boot-time disconnected tick closing the just-opened pairing modal,
8+
* and a dismissed modal leaving the login flow polling forever.
9+
*/
10+
test.describe("login pairing modal", () => {
11+
test("stays open while pairing, cancels on close, reopens on retry", async ({
12+
page,
13+
}) => {
14+
const subscribeSends: number[] = [];
15+
page.on("console", (msg) => {
16+
const text = msg.text();
17+
if (
18+
text.includes("chainSend") &&
19+
text.includes("statement_subscribeStatement")
20+
) {
21+
subscribeSends.push(Date.now());
22+
}
23+
});
24+
await page.addInitScript(() => {
25+
try {
26+
localStorage.setItem("truapi:logLevel", "debug");
27+
} catch {
28+
/* storage unavailable */
29+
}
30+
});
31+
32+
const frame = await openPlaygroundInDotli(page);
33+
await waitForOnline(frame);
34+
35+
// Opening the login modal renders the pairing QR from the core's
36+
// `Pairing` auth state.
37+
await openPairingModal(page);
38+
39+
// The modal must survive the first seconds of pairing: the freshly
40+
// booted core's session-store sync must not tear it down.
41+
await page.waitForTimeout(5_000);
42+
await expect(page.locator("#auth-modal-backdrop.open")).toBeVisible();
43+
await expect(page.locator("#auth-modal-qr canvas")).toBeVisible();
44+
45+
// While pairing, the core polls the statement store with ~2s
46+
// snapshot queries.
47+
expect(subscribeSends.length).toBeGreaterThanOrEqual(2);
48+
49+
// Closing the modal cancels the login in the core: polling stops.
50+
await page.locator("#auth-modal-close").click();
51+
await expect(page.locator("#auth-modal-backdrop.open")).toBeHidden();
52+
await page.waitForTimeout(1_000); // grace for an in-flight tick
53+
const sendsAtCancel = subscribeSends.length;
54+
await page.waitForTimeout(6_000);
55+
expect(subscribeSends.length).toBe(sendsAtCancel);
56+
57+
// Retry opens a fresh pairing modal.
58+
await openPairingModal(page);
59+
});
60+
});
61+
62+
async function openPairingModal(page: Page): Promise<void> {
63+
await page.locator("#auth-button").click();
64+
await expect(page.locator("#auth-modal-backdrop.open")).toBeVisible();
65+
await expect(page.locator("#auth-modal-qr canvas")).toBeVisible({
66+
timeout: 15_000,
67+
});
68+
}

playground/tests/e2e/subscription.spec.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,7 @@ test.describe("subscription", () => {
1919
await expect(entries.first()).toBeVisible({ timeout: 6_000 });
2020
await expect(frame.locator('[data-testid="error-display"]')).toHaveCount(0);
2121

22-
// Once the first event is delivered the run completes and the Run button
23-
// returns (no long-lived stream to Stop).
24-
await expect(
25-
frame.locator('[data-testid="subscribe-button"]'),
26-
).toBeVisible();
22+
const text = await entries.first().innerText();
23+
expect(text).toContain("connection status:");
2724
});
2825
});

0 commit comments

Comments
 (0)