This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 90fc4735835 test(embedded-sdk): cross-document test rig for the
navigation fix (#44608)
90fc4735835 is described below
commit 90fc4735835441a4e242e878b4e0a267460f2b7d
Author: chadek <[email protected]>
AuthorDate: Wed Sep 30 23:52:06 2026 -0300
test(embedded-sdk): cross-document test rig for the navigation fix (#44608)
Co-authored-by: Claude Sonnet 5 <[email protected]>
---
superset-embedded-sdk/testrig/README.md | 89 ++++
superset-embedded-sdk/testrig/drive.mjs | 655 ++++++++++++++++++++++++++++
superset-embedded-sdk/testrig/embedded.html | 127 ++++++
superset-embedded-sdk/testrig/host.html | 239 ++++++++++
superset-embedded-sdk/testrig/server.mjs | 199 +++++++++
5 files changed, 1309 insertions(+)
diff --git a/superset-embedded-sdk/testrig/README.md
b/superset-embedded-sdk/testrig/README.md
new file mode 100644
index 00000000000..de7c1ebb9cf
--- /dev/null
+++ b/superset-embedded-sdk/testrig/README.md
@@ -0,0 +1,89 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with this
+ work for additional information regarding copyright ownership. The ASF
+ licenses this file to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
+-->
+
+# Embedded SDK test rig
+
+A host app and a stand-in for Superset's embedded page, on two origins, so the
+parts of the SDK that only exist between two documents can be exercised for
+real: the MessageChannel handshake, what survives a navigation the dashboard
+makes on its own, and how many times the host's guest-token endpoint is asked
+for a token.
+
+The unit tests mock `MessageChannel` and `Switchboard`. This rig mocks
+neither — it loads the built UMD bundle in Chromium and talks to a page that
+speaks the same handshake as `superset-frontend/src/embedded/index.tsx`, over
+the real `@superset-ui/switchboard`.
+
+## Run it
+
+```bash
+cd superset-embedded-sdk
+node testrig/drive.mjs # headless, builds the bundle if it is stale
+node testrig/drive.mjs --headed # watch it happen
+node testrig/drive.mjs --verbose # + the pages' console output
+```
+
+Needs a `chromium`, `chromium-browser` or `google-chrome` on `PATH`, or
+`CHROMIUM_PATH` pointing at one. No npm dependencies: the driver speaks the
+DevTools protocol over Node's built-in WebSocket.
+
+Exit code is 0 only if every check passed.
+
+## Poke at it by hand
+
+```bash
+node testrig/server.mjs # then open http://localhost:8100
+```
+
+The host app has buttons for embedding, pushing a theme, calling
+`getActiveTabs()` and unmounting; the dashboard page has links that navigate it
+the way a tab or a link in a Markdown chart would. The right-hand pane logs
+what the host saw, and the counter under the frame is how many guest tokens the
+host's endpoint has minted.
+
+`?ttl=15` on the host app shortens the guest token's life to 15 seconds, so the
+refresh timer fires while you are watching. `?hang=1` makes the dashboard page
+answer no `get` at all, which is how the rig produces a call that is still in
+flight when the user clicks a link. `?slowfirst=1` holds the very first
+`fetchGuestToken()` open until you settle it yourself with
+`rigReleaseFirstToken()` or `rigFailFirstToken()` from the console, and
+`?failnth=N` makes call N reject — together they are how the rig reaches the
+race between the initial fetch and a navigation that happens during it.
+
+## What the pieces are
+
+| file | what it stands for |
+| --- | --- |
+| `server.mjs` | the host app's backend (`/guest-token`, which counts what it
mints) on `:8100`, and the Superset instance on `:8200` |
+| `host.html` | the host app: calls `embedDashboard`, drives the public API,
records everything into `window.rig` |
+| `embedded.html` | Superset's embedded page: the same port-transfer
handshake, the same method names, plus links that navigate itself |
+| `drive.mjs` | headless Chromium driver and the checks |
+
+`embedded.html` reports what it experienced to the host through an ordinary
+`postMessage` to `parent`. That side channel is the rig's own and has nothing
+to do with the SDK's MessageChannel — it is how the driver can see what
+happened inside a cross-origin frame.
+
+## What it checks
+
+- a first embed renders, on exactly one minted token
+- a navigation internal to the dashboard re-authenticates the new document
+- the host's theme is re-applied to that document, after its token
+- a `get` still in flight when the user navigates is rejected as a
+ `PortClosedError` rather than left hanging, and so is one caught by
`unmount()`
+- a navigation that does not land on the embedded page mints no token at all,
+ and coming back to the dashboard afterwards works
+- the refresh timer follows the current document, fires once, and leaves the
+ document that is gone alone
+- an initial token fetch that fails *after* a navigation already recovered the
+ embed leaves that working dashboard alone, and `embedDashboard` resolves
+- a token from a superseded cycle is handed straight to the document in front
of
+ the user when that document has none, rather than waiting out a retry
+
+Run it against `git show HEAD~1:...` of `src/index.ts` to watch the checks
fail.
diff --git a/superset-embedded-sdk/testrig/drive.mjs
b/superset-embedded-sdk/testrig/drive.mjs
new file mode 100644
index 00000000000..e91177c0e9b
--- /dev/null
+++ b/superset-embedded-sdk/testrig/drive.mjs
@@ -0,0 +1,655 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Drives the rig in headless Chromium over the DevTools protocol, with no
+// dependencies beyond a chromium binary. `node drive.mjs [--headed]
[--verbose]`
+
+import { spawn, execFileSync } from "node:child_process";
+import { mkdtempSync, existsSync, statSync, readdirSync, rmSync } from
"node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { start, HOST_PORT } from "./server.mjs";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const sdkRoot = join(here, "..");
+const headed = process.argv.includes("--headed");
+const verbose = process.argv.includes("--verbose");
+
+const CHROMIUM =
+ process.env.CHROMIUM_PATH ||
+ ["chromium", "chromium-browser", "google-chrome",
"google-chrome-stable"].find(
+ (bin) => {
+ try {
+ execFileSync("which", [bin], { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ );
+
+// ------------------------------------------------------------------ results
+const results = [];
+function check(name, ok, detail = "") {
+ results.push({ name, ok, detail });
+ const mark = ok ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
+ console.log(` ${mark} ${name}${detail && !ok ? `\n ${detail}` : ""}`);
+}
+
+// Waits for something to become true, and records the wait itself as a check,
+// so a run against code that never gets there reports a failure per scenario
+// instead of stopping at the first one.
+async function expect(name, predicate, timeoutMs = 15_000) {
+ try {
+ await waitFor(predicate, name, timeoutMs);
+ check(name, true);
+ return true;
+ } catch (err) {
+ check(name, false, err.message);
+ return false;
+ }
+}
+
+// ---------------------------------------------------------------- cdp client
+class CDP {
+ constructor(ws) {
+ this.ws = ws;
+ this.nextId = 0;
+ this.pending = new Map();
+ this.listeners = [];
+ ws.addEventListener("message", (event) => {
+ // Nothing upstream catches a throw from this listener, so a frame that
+ // does not parse is dropped rather than taking the whole run down.
+ let msg;
+ try {
+ msg = JSON.parse(event.data);
+ } catch (err) {
+ if (verbose) console.error(` [cdp] dropped an unparsable frame:
${err.message}`);
+ return;
+ }
+ if (msg.id && this.pending.has(msg.id)) {
+ const { resolve, reject } = this.pending.get(msg.id);
+ this.pending.delete(msg.id);
+ if (msg.error) reject(new Error(JSON.stringify(msg.error)));
+ else resolve(msg.result);
+ } else {
+ this.listeners.forEach((fn) => fn(msg));
+ }
+ });
+ }
+
+ send(method, params = {}, sessionId) {
+ const id = (this.nextId += 1);
+ const payload = { id, method, params };
+ if (sessionId) payload.sessionId = sessionId;
+ this.ws.send(JSON.stringify(payload));
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject });
+ setTimeout(() => {
+ if (this.pending.delete(id)) reject(new Error(`${method} timed out`));
+ }, 30_000);
+ });
+ }
+}
+
+// The `stop` of every browser still running, so an interrupted run can stop
+// them too.
+const runningBrowsers = new Set();
+
+async function launchBrowser() {
+ const userDataDir = mkdtempSync(join(tmpdir(), "embedded-sdk-rig-"));
+ const args = [
+ ...(headed ? [] : ["--headless=new"]),
+ "--remote-debugging-port=0",
+ `--user-data-dir=${userDataDir}`,
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--disable-gpu",
+ "--disable-dev-shm-usage",
+ "about:blank",
+ ];
+ // In a process group of its own, so the helper processes chromium forks can
+ // be killed along with it: left alive, they write back into the profile
+ // after it has been removed.
+ const child = spawn(CHROMIUM, args, {
+ stdio: ["ignore", "pipe", "pipe"],
+ detached: true,
+ });
+ const exited = new Promise((resolve) => {
+ child.once("exit", resolve);
+ child.once("error", resolve);
+ });
+ let stopping;
+ const stop = () =>
+ (stopping ??= (async () => {
+ runningBrowsers.delete(stop);
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {
+ // already gone, or never started
+ }
+ await exited;
+ rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3 });
+ })());
+ runningBrowsers.add(stop);
+ // Until `stop` is handed back, nobody else can clean up after this browser.
+ try {
+ return { cdp: await connect(child), stop };
+ } catch (err) {
+ await stop();
+ throw err;
+ }
+}
+
+async function connect(child) {
+ const wsUrl = await new Promise((resolve, reject) => {
+ let buffered = "";
+ const onChunk = (chunk) => {
+ buffered += chunk;
+ const match = buffered.match(/ws:\/\/\S+/);
+ if (match) resolve(match[0]);
+ };
+ child.stdout.on("data", onChunk);
+ child.stderr.on("data", onChunk);
+ child.on("exit", (code) =>
+ reject(new Error(`chromium exited (${code}) before
listening:\n${buffered}`)),
+ );
+ child.on("error", (err) =>
+ reject(new Error(`could not start ${CHROMIUM}: ${err.message}`)),
+ );
+ setTimeout(() => reject(new Error("chromium never reported a devtools
url")), 20_000);
+ });
+ const ws = new WebSocket(wsUrl);
+ await new Promise((resolve, reject) => {
+ ws.addEventListener("open", resolve, { once: true });
+ ws.addEventListener(
+ "error",
+ () => reject(new Error(`could not connect to chromium's devtools at
${wsUrl}`)),
+ { once: true },
+ );
+ });
+ return new CDP(ws);
+}
+
+// ------------------------------------------------------------------- a page
+async function openPage(cdp, url) {
+ const { targetId } = await cdp.send("Target.createTarget", { url:
"about:blank" });
+ const { sessionId } = await cdp.send("Target.attachToTarget", {
+ targetId,
+ flatten: true,
+ });
+ await cdp.send("Runtime.enable", {}, sessionId);
+ await cdp.send("Page.enable", {}, sessionId);
+ if (verbose) {
+ cdp.listeners.push((msg) => {
+ if (msg.method === "Runtime.consoleAPICalled" && msg.sessionId ===
sessionId) {
+ const text = msg.params.args
+ .map((a) => a.value ?? a.description ?? a.type)
+ .join(" ");
+ console.log(` [page] ${text}`);
+ }
+ });
+ }
+
+ const evaluate = async (expression) => {
+ const r = await cdp.send(
+ "Runtime.evaluate",
+ { expression, awaitPromise: true, returnByValue: true },
+ sessionId,
+ );
+ if (r.exceptionDetails) {
+ throw new Error(
+ r.exceptionDetails.exception?.description ||
+ r.exceptionDetails.text ||
+ JSON.stringify(r.exceptionDetails),
+ );
+ }
+ return r.result.value;
+ };
+
+ await cdp.send("Page.navigate", { url }, sessionId);
+ // Wait for the host app's own globals rather than a load event.
+ await waitFor(
+ () => evaluate("typeof window.rig === 'object' &&
!!window.supersetEmbeddedSdk"),
+ "the host app to load",
+ );
+ return { evaluate, sessionId };
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// A rejected predicate counts as "not yet", not as a failure: a navigation
that
+// destroys the execution context mid-poll makes `evaluate` reject, and that
+// navigation is exactly the race this rig exists to reproduce.
+async function waitFor(predicate, what, timeoutMs = 15_000, intervalMs = 100) {
+ const deadline = Date.now() + timeoutMs;
+ let lastError;
+ for (;;) {
+ try {
+ const value = await predicate();
+ if (value) return value;
+ } catch (err) {
+ lastError = err;
+ }
+ if (Date.now() > deadline) {
+ throw new Error(
+ `timed out waiting for ${what}${
+ lastError ? ` (last error: ${lastError.message})` : ""
+ }`,
+ );
+ }
+ await sleep(intervalMs);
+ }
+}
+
+// ------------------------------------------------------------------ helpers
+function makeHelpers(evaluate) {
+ return {
+ evaluate,
+ click: (id) =>
evaluate(`document.getElementById(${JSON.stringify(id)}).click()`),
+ events: () => evaluate("window.rig.events"),
+ errors: () => evaluate("window.rig.errors"),
+ tokensMinted: () => evaluate("window.rigRefreshStats().then(s =>
s.tokensMinted)"),
+ navigateGuest: (to) =>
evaluate(`window.rigNavigateGuest(${JSON.stringify(to)})`),
+ embedState: () => evaluate("window.rig.embedState"),
+ releaseFirstToken: () => evaluate("window.rigReleaseFirstToken()"),
+ failFirstToken: () => evaluate("window.rigFailFirstToken()"),
+ heldFirstFetch: () => evaluate("!!window.rig.heldFirstFetch"),
+ hasIframe: () => evaluate("!!document.querySelector('#mount iframe')"),
+ getActiveTabs: () => evaluate("window.rigGetActiveTabs()"),
+ // Events the embedded page reported from a given page of the dashboard.
+ eventsOn: async (page, name) =>
+ (await evaluate("window.rig.events")).filter(
+ (e) => e.page === String(page) && (!name || e.event === name),
+ ),
+ };
+}
+
+// ----------------------------------------------------------------- the runs
+async function mainRun(cdp, hostOrigin) {
+ console.log("\n\x1b[1mmain run\x1b[0m (300s tokens)");
+ const { evaluate } = await openPage(cdp, `${hostOrigin}/`);
+ const h = makeHelpers(evaluate);
+
+ // -- 1. the ordinary first embed -----------------------------------------
+ await h.click("embed");
+ await expect(
+ "first load: the embedded page renders with a guest token",
+ async () => (await h.eventsOn(1, "guestToken")).length === 1,
+ );
+ check(
+ "first load: exactly one token minted",
+ (await h.tokensMinted()) === 1,
+ `minted ${await h.tokensMinted()}`,
+ );
+
+ // -- 2. the bug this PR is about ------------------------------------------
+ await
evaluate("window.rig.dashboard.setThemeConfig({token:{colorPrimary:'#ff0066'}})");
+ await evaluate("window.rig.dashboard.setThemeMode('dark')");
+ await expect(
+ "first load: the host's theme reaches the document",
+ async () => (await h.eventsOn(1, "setThemeMode")).length === 1,
+ );
+
+ await h.navigateGuest("/embedded/rig-dashboard?page=2");
+ await expect(
+ "internal navigation: the new document is re-authenticated",
+ async () => (await h.eventsOn(2, "guestToken")).length === 1,
+ );
+ check(
+ "internal navigation: one further token minted, not more",
+ (await h.tokensMinted()) === 2,
+ `minted ${await h.tokensMinted()}`,
+ );
+
+ // The finding this round of fixes came from: methods were replayed, state
+ // was not, so the dashboard came back in the default theme.
+ await expect(
+ "internal navigation: the host's theme is re-applied",
+ async () => (await h.eventsOn(2, "setThemeMode")).length === 1,
+ );
+ const page2 = (await h.eventsOn(2)).map((e) => e.event);
+ check(
+ "internal navigation: the token arrives before the theme",
+ page2.indexOf("guestToken") < page2.indexOf("setThemeConfig"),
+ page2.join(" → "),
+ );
+ check(
+ "internal navigation: the dark theme is actually on the new document",
+ await evaluate(
+ "document.querySelector('#mount iframe') && window.rig.events.some(e =>
e.page==='2' && e.event==='setThemeMode' && e.detail.mode==='dark')",
+ ),
+ );
+
+ // -- 3. a call in flight when the document leaves --------------------------
+ await h.navigateGuest("/embedded/rig-dashboard?page=3&hang=1");
+ await expect(
+ "a page that answers nothing still gets its token",
+ async () => (await h.eventsOn(3, "guestToken")).length === 1,
+ );
+ await h.click("tabs"); // page 3 never answers this
+ await sleep(300);
+ await h.navigateGuest("/embedded/rig-dashboard?page=4");
+ const rejected = await expect(
+ "a call in flight when the user navigates is rejected, not left hanging",
+ async () => (await h.errors()).length === 1,
+ );
+ if (rejected) {
+ const [err] = await h.errors();
+ check(
+ "…and it is rejected as a PortClosedError",
+ err.name === "PortClosedError",
+ JSON.stringify(err),
+ );
+ }
+
+ // -- 4. a navigation that does not land on the embedded page --------------
+ const beforeStray = await h.tokensMinted();
+ await h.navigateGuest("/plain");
+ // The handshake probe gives the new document 5s to answer.
+ await sleep(7000);
+ check(
+ "a navigation away from the embedded page mints no guest token",
+ (await h.tokensMinted()) === beforeStray,
+ `minted ${await h.tokensMinted()}, was ${beforeStray}`,
+ );
+
+ // -- 5. and back again ----------------------------------------------------
+ // The stray page cannot navigate itself back for us, so the host reloads the
+ // frame the way a host app would.
+ await evaluate(
+ "document.querySelector('#mount iframe').src =
'__SUPERSET__/embedded/rig-dashboard?page=5&hang=1'".replace(
+ "__SUPERSET__",
+ await evaluate("SUPERSET_ORIGIN"),
+ ),
+ );
+ await expect(
+ "coming back to the embedded page re-authenticates again",
+ async () => (await h.eventsOn(5, "guestToken")).length === 1,
+ );
+ check(
+ "coming back mints exactly one more token",
+ (await h.tokensMinted()) === beforeStray + 1,
+ `minted ${await h.tokensMinted()}, was ${beforeStray}`,
+ );
+
+ // -- 6. unmount -----------------------------------------------------------
+ // Page 5 answers nothing either, so this call is still in flight.
+ await h.click("tabs");
+ await sleep(200);
+ await evaluate("window.rig.dashboard.unmount()");
+ if (
+ await expect(
+ "unmount rejects what is still in flight",
+ async () => (await h.errors()).length === 2,
+ )
+ ) {
+ const errs = await h.errors();
+ check(
+ "…and it too is a PortClosedError",
+ errs[1].name === "PortClosedError",
+ JSON.stringify(errs[1]),
+ );
+ }
+ const afterUnmount = await h.tokensMinted();
+ await sleep(1000);
+ check(
+ "nothing is minted after unmount",
+ (await h.tokensMinted()) === afterUnmount,
+ );
+}
+
+async function refreshRun(cdp, hostOrigin) {
+ console.log("\n\x1b[1mrefresh run\x1b[0m (15s tokens, so the timer fires
during the run)");
+ await fetch(`${hostOrigin}/reset`);
+ const { evaluate } = await openPage(cdp, `${hostOrigin}/?ttl=15`);
+ const h = makeHelpers(evaluate);
+
+ await h.click("embed");
+ await expect(
+ "refresh run: the first token arrives",
+ async () => (await h.eventsOn(1, "guestToken")).length === 1,
+ );
+ await h.navigateGuest("/embedded/rig-dashboard?page=2");
+ await expect(
+ "refresh run: the navigated-to page is authenticated",
+ async () => (await h.eventsOn(2, "guestToken")).length === 1,
+ );
+ const mintedAfterNav = await h.tokensMinted();
+
+ // A 15s token refreshes 5s before it expires, so ~10s from when it was
+ // issued. The point is which port that refresh lands on.
+ await expect(
+ "the refresh timer follows the current document",
+ async () => (await h.eventsOn(2, "guestToken")).length === 2,
+ 20_000,
+ );
+ check(
+ "the old document gets nothing",
+ (await h.eventsOn(1, "guestToken")).length === 1,
+ );
+ check(
+ "exactly one refresh, so there is only one timer chain",
+ (await h.tokensMinted()) === mintedAfterNav + 1,
+ `minted ${await h.tokensMinted()}, was ${mintedAfterNav}`,
+ );
+ await evaluate("window.rig.dashboard?.unmount()");
+}
+
+
+// The two directions of the race between the very first token fetch and a
+// navigation the dashboard makes while that fetch is still in flight. Neither
+// is reachable with a host endpoint that answers promptly, so the rig holds
+// the first fetch open and settles it by hand.
+async function staleInitialFetchRun(cdp, hostOrigin) {
+ console.log(
+ "\n\x1b[1mstale initial fetch run\x1b[0m (the first fetch fails after a
navigation already recovered the embed)",
+ );
+ await fetch(`${hostOrigin}/reset`);
+ const { evaluate } = await openPage(cdp, `${hostOrigin}/?slowfirst=1`);
+ const h = makeHelpers(evaluate);
+
+ await h.click("embed");
+ // The frame is up and page 1 is blank, waiting for a token that the rig is
+ // sitting on.
+ await expect("stale initial fetch: the first fetch is in flight",
h.heldFirstFetch);
+ await expect(
+ "stale initial fetch: the first document is still waiting for a token",
+ async () => (await h.eventsOn(1, "started")).length === 1,
+ );
+ check(
+ "stale initial fetch: …and has not been given one",
+ (await h.eventsOn(1, "guestToken")).length === 0,
+ );
+
+ // The user clicks a link before the host's endpoint has answered.
+ await h.navigateGuest("/embedded/rig-dashboard?page=2");
+ await expect(
+ "stale initial fetch: the navigated-to document authenticates on its own",
+ async () => (await h.eventsOn(2, "guestToken")).length === 1,
+ );
+ const mintedWhileWorking = await h.tokensMinted();
+
+ // Only now does the original fetch give up. Nobody is waiting for it.
+ await h.failFirstToken();
+ await sleep(500);
+
+ check(
+ "stale initial fetch: the working dashboard is not torn down",
+ await h.hasIframe(),
+ );
+ check(
+ "stale initial fetch: embedDashboard resolves rather than rejecting",
+ (await h.embedState()) === "resolved",
+ `embedState = ${await h.embedState()}`,
+ );
+ // The surest proof the embed is still live: the port still answers, with the
+ // navigated-to document's own answer.
+ const tabs = await h.getActiveTabs();
+ check(
+ "stale initial fetch: the port still answers, from the current document",
+ tabs.ok && tabs.tabs?.[0] === "tab-on-page-2",
+ JSON.stringify(tabs),
+ );
+ check(
+ "stale initial fetch: the failure mints nothing further",
+ (await h.tokensMinted()) === mintedWhileWorking,
+ `minted ${await h.tokensMinted()}, was ${mintedWhileWorking}`,
+ );
+ await evaluate("window.rig.dashboard?.unmount()");
+}
+
+async function supersededTokenRun(cdp, hostOrigin) {
+ console.log(
+ "\n\x1b[1msuperseded token run\x1b[0m (the navigation's own fetch fails,
the first one succeeds late)",
+ );
+ await fetch(`${hostOrigin}/reset`);
+ // Call #1 is held; call #2 — the one the navigation triggers — rejects.
+ const { evaluate } = await openPage(cdp,
`${hostOrigin}/?slowfirst=1&failnth=2`);
+ const h = makeHelpers(evaluate);
+
+ await h.click("embed");
+ await expect("superseded token: the first fetch is in flight",
h.heldFirstFetch);
+ // The first document has to be listening before it can be told to navigate:
+ // the held fetch starts before the iframe has loaded.
+ await expect(
+ "superseded token: the first document is up and waiting for a token",
+ async () => (await h.eventsOn(1, "started")).length === 1,
+ );
+
+ await h.navigateGuest("/embedded/rig-dashboard?page=2");
+ // The navigation's own fetch fails, so page 2 is blank and a retry is armed
+ // ten seconds out.
+ await expect(
+ "superseded token: the navigated-to document reaches the handshake",
+ async () => (await h.eventsOn(2, "started")).length === 1,
+ );
+ await expect(
+ "superseded token: its own fetch failed, so it has no token yet",
+ async () => (await evaluate("window.rig.tokenFetches")) === 2,
+ );
+ check(
+ "superseded token: …and the document is still blank",
+ (await h.eventsOn(2, "guestToken")).length === 0,
+ );
+
+ // The first fetch finally answers. Its token is superseded but perfectly
+ // valid, and the document in front of the user has none.
+ await h.releaseFirstToken();
+ // Well inside the 10s retry interval: holding the token back until the
failed
+ // cycle retries is exactly the blank page this is about, so a token that
only
+ // turns up on the retry is not a pass.
+ await expect(
+ "superseded token: the superseded token is handed straight to the current
document",
+ async () => (await h.eventsOn(2, "guestToken")).length === 1,
+ 3_000,
+ );
+ check(
+ "superseded token: nothing is sent to the document that is gone",
+ (await h.eventsOn(1, "guestToken")).length === 0,
+ );
+ check(
+ "superseded token: one token minted, not a second for the retry",
+ (await h.tokensMinted()) === 1,
+ `minted ${await h.tokensMinted()}`,
+ );
+ check(
+ "superseded token: embedDashboard resolves",
+ (await h.embedState()) === "resolved",
+ `embedState = ${await h.embedState()}`,
+ );
+ await evaluate("window.rig.dashboard?.unmount()");
+}
+
+// --------------------------------------------------------------------- main
+// Everything the bundle is built from, not just the sources: a change to the
+// build config or the dependencies is just as able to make it stale.
+const BUILD_INPUTS = [
+ "webpack.config.js",
+ "babel.config.js",
+ "tsconfig.json",
+ "package.json",
+ "package-lock.json",
+];
+
+function buildIfStale() {
+ const bundle = join(sdkRoot, "bundle", "index.js");
+ const inputs = [
+ ...readdirSync(join(sdkRoot, "src"), { recursive: true }).map((f) =>
+ join("src", f),
+ ),
+ ...BUILD_INPUTS,
+ ]
+ .map((f) => join(sdkRoot, f))
+ .filter((f) => existsSync(f) && statSync(f).isFile());
+ const newestInput = Math.max(...inputs.map((f) => statSync(f).mtimeMs));
+ if (existsSync(bundle) && statSync(bundle).mtimeMs > newestInput) return;
+ console.log("building the sdk bundle…");
+ execFileSync("npx", ["webpack", "--mode", "development"], {
+ cwd: sdkRoot,
+ stdio: verbose ? "inherit" : "ignore",
+ });
+}
+
+async function main() {
+ if (!CHROMIUM) {
+ console.error(
+ "no chromium found. Install one, or set CHROMIUM_PATH to a chrome
binary.",
+ );
+ process.exit(2);
+ }
+ buildIfStale();
+ // Both are started inside the `try`, so whichever of them did come up is
+ // stopped even when the other one fails to.
+ let server;
+ let browser;
+ try {
+ server = await start();
+ browser = await launchBrowser();
+ await mainRun(browser.cdp, server.hostOrigin);
+ await refreshRun(browser.cdp, server.hostOrigin);
+ await staleInitialFetchRun(browser.cdp, server.hostOrigin);
+ await supersededTokenRun(browser.cdp, server.hostOrigin);
+ } finally {
+ await browser?.stop();
+ server?.stop();
+ }
+
+ const failed = results.filter((r) => !r.ok);
+ console.log(
+ `\n${results.length - failed.length}/${results.length} checks passed`,
+ );
+ process.exit(failed.length ? 1 : 0);
+}
+
+// Chromium runs in its own process group, so a Ctrl-C no longer reaches it on
+// its own. The servers need nothing: they go with this process.
+for (const signal of ["SIGINT", "SIGTERM"]) {
+ process.once(signal, async () => {
+ console.error(`\n${signal}: stopping the rig`);
+ await Promise.all([...runningBrowsers].map((stop) => stop()));
+ process.exit(130);
+ });
+}
+
+main().catch((err) => {
+ console.error("\nrig failed:", err);
+ process.exit(1);
+});
diff --git a/superset-embedded-sdk/testrig/embedded.html
b/superset-embedded-sdk/testrig/embedded.html
new file mode 100644
index 00000000000..574d06e2543
--- /dev/null
+++ b/superset-embedded-sdk/testrig/embedded.html
@@ -0,0 +1,127 @@
+<!doctype html>
+<html lang="en">
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with this
+ work for additional information regarding copyright ownership. The ASF
+ licenses this file to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
+-->
+<head>
+ <meta charset="utf-8" />
+ <title>rig: embedded dashboard</title>
+ <style>
+ :root { color-scheme: light; --bg: #fff; --fg: #1b1b1b; --muted: #666; }
+ body.dark { color-scheme: dark; --bg: #16181d; --fg: #e8e8e8; --muted:
#9aa; }
+ body { margin: 0; padding: 20px; font: 14px/1.5 system-ui, sans-serif;
+ background: var(--bg); color: var(--fg); }
+ h1 { font-size: 16px; margin: 0 0 4px; }
+ .muted { color: var(--muted); }
+ .chart { margin: 16px 0; padding: 16px; border: 1px solid #8884;
border-radius: 8px; }
+ .blank { color: #b00; font-weight: 600; }
+ a { color: inherit; }
+ code { font-family: ui-monospace, monospace; }
+ </style>
+</head>
+<body>
+ <h1>Embedded dashboard — <span id="page">page 1</span></h1>
+ <p class="muted">This page stands in for Superset's embedded page. It speaks
+ the same handshake as
<code>superset-frontend/src/embedded/index.tsx</code>.</p>
+
+ <div class="chart">
+ <div id="content" class="blank">blank: waiting for a guest token…</div>
+ <div class="muted" id="detail"></div>
+ </div>
+
+ <p>
+ <!-- A link internal to the dashboard: a tab, a drill-down, a link in a
+ Markdown chart. It navigates this same iframe. -->
+ <a id="internal-link" href="#">go to page 2 (link internal to the
dashboard)</a><br />
+ <a id="offsite-link" href="/plain">go to a page that is not the embedded
one</a>
+ </p>
+
+<script type="module">
+ import Switchboard from "/vendor/switchboard.js";
+
+ const params = new URLSearchParams(location.search);
+ const page = params.get("page") || "1";
+ document.getElementById("page").textContent = `page ${page}`;
+ document.getElementById("internal-link").href =
+ `${location.pathname}?page=${Number.isNaN(+page) ? 2 : +page + 1}` +
+ (params.get("hang") ? "&hang=1" : "");
+
+ // The rig's own side channel, so the driver can see what this document
+ // experienced. It is nothing to do with the SDK's MessageChannel.
+ const report = (event, detail) =>
+ parent.postMessage({ __rig: true, page, event, detail }, "*");
+
+ let tokensReceived = 0;
+
+ function render(detail) {
+ const el = document.getElementById("content");
+ el.className = "";
+ el.textContent = `rendered with a guest token (page ${page})`;
+ document.getElementById("detail").textContent = detail;
+ }
+
+ // Mirrors the real page: everything is defined inside the port-transfer
+ // handler, and `start()` comes last.
+ window.addEventListener("message", function embeddedPageInitializer(event) {
+ const port = event.ports?.[0];
+ if (event.data?.type !== "__embedded_comms__") return;
+ if (event.data.handshake === "port transfer" && port) {
+ report("handshake");
+
+ Switchboard.init({ port, name: "superset", debug: false });
+
+ Switchboard.defineMethod("guestToken", ({ guestToken }) => {
+ tokensReceived += 1;
+ const claims = JSON.parse(atob(guestToken.split(".")[1]));
+ render(`token #${claims.serial}, exp in ${
+ claims.exp - Math.floor(Date.now() / 1000)
+ }s, ${tokensReceived} received on this port`);
+ report("guestToken", { serial: claims.serial, tokensReceived });
+ });
+
+ Switchboard.defineMethod("getActiveTabs", () => {
+ // `?hang=1` makes this page answer nothing, standing in for a reply
+ // that is still being computed when the user clicks a link.
+ if (params.get("hang")) return new Promise(() => {});
+ return [`tab-on-page-${page}`];
+ });
+
+ Switchboard.defineMethod("getScrollSize", () => {
+ if (params.get("hang")) return new Promise(() => {});
+ return { width: 800, height: 600 };
+ });
+
+ Switchboard.defineMethod("setThemeConfig", ({ themeConfig }) => {
+ report("setThemeConfig", themeConfig);
+ return { success: true };
+ });
+
+ Switchboard.defineMethod("setThemeMode", ({ mode }) => {
+ document.body.classList.toggle("dark", mode === "dark");
+ report("setThemeMode", { mode });
+ return { success: true };
+ });
+
+ Switchboard.start();
+ report("started");
+ }
+ });
+
+ // The rig drives navigation the way the dashboard itself would: this
+ // document sets its own location. The host has no other way to reach into a
+ // cross-origin frame, and neither would a real one.
+ window.addEventListener("message", (event) => {
+ if (event.data?.__rigCommand === "navigate") {
+ location.href = event.data.to;
+ }
+ });
+
+ report("loaded");
+</script>
+</body>
+</html>
diff --git a/superset-embedded-sdk/testrig/host.html
b/superset-embedded-sdk/testrig/host.html
new file mode 100644
index 00000000000..cc1c54bfd23
--- /dev/null
+++ b/superset-embedded-sdk/testrig/host.html
@@ -0,0 +1,239 @@
+<!doctype html>
+<html lang="en">
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with this
+ work for additional information regarding copyright ownership. The ASF
+ licenses this file to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
+-->
+<head>
+ <meta charset="utf-8" />
+ <title>embedded-sdk test rig — host app</title>
+ <style>
+ body { margin: 0; font: 14px/1.5 system-ui, sans-serif; display: grid;
+ grid-template-columns: 1fr 420px; height: 100vh; }
+ main { padding: 16px; overflow: auto; }
+ aside { border-left: 1px solid #ddd; padding: 16px; overflow: auto;
background: #fafafa; }
+ h1 { font-size: 16px; margin: 0 0 12px; }
+ #mount { border: 1px solid #ccc; border-radius: 8px; height: 320px; }
+ #mount iframe { width: 100%; height: 100%; border: 0; border-radius: 8px; }
+ button { font: inherit; margin: 0 6px 6px 0; padding: 4px 10px; }
+ #log { font: 12px/1.45 ui-monospace, monospace; white-space: pre-wrap; }
+ .t { color: #888; }
+ .err { color: #b00; }
+ .ok { color: #070; }
+ </style>
+</head>
+<body>
+ <main>
+ <h1>Host app</h1>
+ <div>
+ <button id="embed">embed</button>
+ <button id="theme">setThemeMode(dark) + setThemeConfig</button>
+ <button id="tabs">getActiveTabs()</button>
+ <button id="unmount">unmount</button>
+ <button id="clear">clear log</button>
+ </div>
+ <div id="mount"></div>
+ <p class="t">Tokens minted by this host's endpoint: <b
id="minted">0</b></p>
+ </main>
+ <aside><div id="log"></div></aside>
+
+<script src="/sdk.js"></script>
+<script>
+ const SUPERSET_ORIGIN = "__SUPERSET_ORIGIN__";
+
+ // Everything the driver reads.
+ const rig = (window.rig = {
+ events: [], // what the embedded page reported over its side channel
+ log: [], // what the host app saw
+ errors: [], // rejections the SDK handed back
+ tokenFetches: 0, // calls the SDK made to fetchGuestToken
+ dashboard: null,
+ embedState: "idle", // pending | resolved | rejected
+ embedError: null,
+ heldFirstFetch: null,
+ });
+
+ function log(kind, ...parts) {
+ const line = `${new Date().toISOString().slice(11, 23)} ${kind} ${parts
+ .map((p) => (typeof p === "string" ? p : JSON.stringify(p)))
+ .join(" ")}`;
+ rig.log.push(line);
+ const el = document.createElement("div");
+ el.className = kind === "error" ? "err" : kind === "ok" ? "ok" : "";
+ el.textContent = line;
+ document.getElementById("log").prepend(el);
+ }
+
+ // The embedded page's side channel (not the SDK's MessageChannel).
+ window.addEventListener("message", (event) => {
+ if (!event.data?.__rig) return;
+ rig.events.push({ ...event.data, at: Date.now() });
+ log("guest", `page ${event.data.page}`, event.data.event,
event.data.detail ?? "");
+ });
+
+ async function refreshStats() {
+ const stats = await fetch("/stats").then((r) => r.json());
+ document.getElementById("minted").textContent = stats.tokensMinted;
+ rig.tokensMinted = stats.tokensMinted;
+ return stats;
+ }
+ window.rigRefreshStats = refreshStats;
+
+ // TTL is a query param so a run can exercise the refresh timer in seconds
+ // rather than minutes.
+ const search = new URLSearchParams(location.search);
+ const ttl = search.get("ttl") || "300";
+ // `?slowfirst=1` holds the very first fetchGuestToken() until the driver
+ // settles it by hand, which is the only way to have a navigation happen
+ // while the initial token is still in flight. `?failnth=N` makes call N
+ // reject, standing in for a host endpoint that is down.
+ const slowFirst = search.get("slowfirst");
+ const failNth = Number(search.get("failnth") || 0);
+
+ async function mintToken() {
+ const { token } = await fetch(`/guest-token?ttl=${ttl}`).then((r) =>
r.json());
+ refreshStats();
+ return token;
+ }
+
+ async function fetchGuestToken() {
+ rig.tokenFetches += 1;
+ const call = rig.tokenFetches;
+ log("host", `fetchGuestToken() #${call}`);
+ if (call === failNth) {
+ log("error", `fetchGuestToken() #${call} rejects (rig)`);
+ throw new Error(`rig: host token endpoint is down (call #${call})`);
+ }
+ if (slowFirst && call === 1) {
+ log("host", "fetchGuestToken() #1 held by the rig");
+ return new Promise((resolve, reject) => {
+ rig.heldFirstFetch = { resolve, reject };
+ });
+ }
+ return mintToken();
+ }
+
+ // The held first fetch, handed out once so it cannot be settled twice.
+ function takeHeldFirstFetch(action) {
+ const held = rig.heldFirstFetch;
+ if (!held) {
+ log("error", `${action}: no fetchGuestToken() is being held (needs
?slowfirst=1)`);
+ return null;
+ }
+ rig.heldFirstFetch = null;
+ return held;
+ }
+
+ // Settle the held first fetch, long after the SDK asked for it.
+ window.rigReleaseFirstToken = async () => {
+ const held = takeHeldFirstFetch("rigReleaseFirstToken");
+ if (!held) return;
+ try {
+ held.resolve(await mintToken());
+ log("host", "released the held fetchGuestToken() #1");
+ } catch (err) {
+ // Never leave the SDK waiting on a fetch nobody will settle.
+ held.reject(err);
+ log("error", `could not mint the held token, failed fetchGuestToken()
#1: ${err.message}`);
+ }
+ };
+ window.rigFailFirstToken = () => {
+ const held = takeHeldFirstFetch("rigFailFirstToken");
+ if (!held) return;
+ held.reject(new Error("rig: host token endpoint is down"));
+ log("error", "failed the held fetchGuestToken() #1");
+ };
+
+ document.getElementById("embed").onclick = () => {
+ // Deliberately not awaited: a run needs to watch what happens while
+ // `embedDashboard` is still pending, and to see how it ends up settling.
+ rig.embedState = "pending";
+ supersetEmbeddedSdk
+ .embedDashboard({
+ id: "rig-dashboard",
+ supersetDomain: SUPERSET_ORIGIN,
+ mountPoint: document.getElementById("mount"),
+ fetchGuestToken,
+ debug: true,
+ dashboardUiConfig: search.get("hang")
+ ? { urlParams: { hang: "1" } }
+ : undefined,
+ })
+ .then(
+ (dashboard) => {
+ rig.dashboard = dashboard;
+ rig.embedState = "resolved";
+ log("ok", "embedDashboard resolved");
+ },
+ (err) => {
+ rig.embedState = "rejected";
+ rig.embedError = { name: err.name, message: err.message };
+ log("error", `embedDashboard rejected: ${err.message}`);
+ },
+ );
+ };
+
+ // The buttons are live before `embedDashboard` resolves, and stay live if it
+ // rejects, so each one checks there is a dashboard to act on.
+ function hasDashboard(action) {
+ if (rig.dashboard) return true;
+ log("error", `${action}: no dashboard yet (embedDashboard is
${rig.embedState})`);
+ return false;
+ }
+
+ document.getElementById("theme").onclick = () => {
+ if (!hasDashboard("theme")) return;
+ rig.dashboard.setThemeConfig({ token: { colorPrimary: "#ff0066" } });
+ rig.dashboard.setThemeMode("dark");
+ log("host", "theme pushed");
+ };
+
+ document.getElementById("tabs").onclick = () => {
+ if (!hasDashboard("getActiveTabs()")) return;
+ log("host", "getActiveTabs() called");
+ rig.dashboard.getActiveTabs().then(
+ (tabs) => log("ok", "getActiveTabs ->", tabs),
+ (err) => {
+ rig.errors.push({ name: err.name, message: err.message, at: Date.now()
});
+ log("error", `getActiveTabs rejected: ${err.name}: ${err.message}`);
+ },
+ );
+ };
+
+ document.getElementById("unmount").onclick = () => {
+ if (!hasDashboard("unmount()")) return;
+ rig.dashboard.unmount();
+ log("host", "unmount()");
+ };
+
+ document.getElementById("clear").onclick = () => {
+ document.getElementById("log").textContent = "";
+ };
+
+ // Same call as the button, but handing the outcome back to the driver, so a
+ // run can tell "the port answered" from "there was nothing to ask".
+ window.rigGetActiveTabs = () => {
+ if (!rig.dashboard) return Promise.resolve({ ok: false, name:
"NoDashboard" });
+ return rig.dashboard.getActiveTabs().then(
+ (tabs) => ({ ok: true, tabs }),
+ (err) => ({ ok: false, name: err.name, message: err.message }),
+ );
+ };
+
+ // Ask the embedded page to navigate itself, which is what a tab, a
+ // drill-down or a link in a Markdown chart does.
+ window.rigNavigateGuest = (to) => {
+ document
+ .querySelector("#mount iframe")
+ .contentWindow.postMessage({ __rigCommand: "navigate", to },
SUPERSET_ORIGIN);
+ log("host", "asked the dashboard to navigate to", to);
+ };
+
+ refreshStats();
+</script>
+</body>
+</html>
diff --git a/superset-embedded-sdk/testrig/server.mjs
b/superset-embedded-sdk/testrig/server.mjs
new file mode 100644
index 00000000000..afa56918761
--- /dev/null
+++ b/superset-embedded-sdk/testrig/server.mjs
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Two origins, because that is what the SDK actually deals with: the host app
+// on one, the Superset instance on the other. No dependencies.
+
+import { createServer } from "node:http";
+import { readFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const sdkRoot = join(here, "..");
+
+export const HOST_PORT = Number(process.env.RIG_HOST_PORT || 8100);
+export const SUPERSET_PORT = Number(process.env.RIG_SUPERSET_PORT || 8200);
+
+const hostOrigin = `http://localhost:${HOST_PORT}`;
+const supersetOrigin = `http://localhost:${SUPERSET_PORT}`;
+
+/** Every guest token this rig has minted, so a test can count them. */
+const minted = [];
+
+function base64url(value) {
+ return Buffer.from(value).toString("base64url");
+}
+
+/** A JWT the SDK can actually decode, since it reads `exp` off the token. */
+function mintGuestToken(ttlSeconds) {
+ const claims = {
+ exp: Math.floor(Date.now() / 1000) + ttlSeconds,
+ user: { username: "rig" },
+ resources: [{ type: "dashboard", id: "rig-dashboard" }],
+ serial: minted.length + 1,
+ };
+ const token = `${base64url('{"alg":"HS256","typ":"JWT"}')}.${base64url(
+ JSON.stringify(claims),
+ )}.rig-signature`;
+ minted.push({ at: Date.now(), ttlSeconds, serial: claims.serial });
+ return token;
+}
+
+function send(res, status, body, type = "text/html; charset=utf-8") {
+ res.writeHead(status, {
+ "content-type": type,
+ "cache-control": "no-store",
+ // The host page needs to call the token endpoint from its own origin only,
+ // but the Superset origin loads nothing from here, so this stays simple.
+ "access-control-allow-origin": "*",
+ });
+ res.end(body);
+}
+
+async function sendFile(res, path, type) {
+ try {
+ send(res, 200, await readFile(path), type);
+ } catch (err) {
+ send(res, 404, `not found: ${path}\n${err.message}`, "text/plain");
+ }
+}
+
+// ---------------------------------------------------------------- host app
+const hostServer = createServer(async (req, res) => {
+ const url = new URL(req.url, hostOrigin);
+
+ if (url.pathname === "/" || url.pathname === "/index.html") {
+ const html = await readFile(join(here, "host.html"), "utf8");
+ return send(
+ res,
+ 200,
+ html.replace("__SUPERSET_ORIGIN__", supersetOrigin),
+ );
+ }
+ // The SDK exactly as a host app consumes it: the built UMD bundle.
+ if (url.pathname === "/sdk.js") {
+ return sendFile(res, join(sdkRoot, "bundle", "index.js"),
"text/javascript");
+ }
+ if (url.pathname === "/sdk.js.map") {
+ return sendFile(
+ res,
+ join(sdkRoot, "bundle", "index.js.map"),
+ "application/json",
+ );
+ }
+ // The host app's own guest-token endpoint. Counting the calls to it is how
+ // the rig proves the SDK does not mint a token per stray navigation.
+ if (url.pathname === "/guest-token") {
+ const ttl = Number(url.searchParams.get("ttl") || 300);
+ return send(
+ res,
+ 200,
+ JSON.stringify({ token: mintGuestToken(ttl) }),
+ "application/json",
+ );
+ }
+ if (url.pathname === "/stats") {
+ return send(
+ res,
+ 200,
+ JSON.stringify({ tokensMinted: minted.length, minted }),
+ "application/json",
+ );
+ }
+ if (url.pathname === "/reset") {
+ minted.length = 0;
+ return send(res, 200, JSON.stringify({ ok: true }), "application/json");
+ }
+ return send(res, 404, "not found", "text/plain");
+});
+
+// ------------------------------------------------------------ fake superset
+const supersetServer = createServer(async (req, res) => {
+ const url = new URL(req.url, supersetOrigin);
+
+ // What the SDK points the iframe at: /embedded/<uuid>
+ if (url.pathname.startsWith("/embedded/")) {
+ return sendFile(res, join(here, "embedded.html"), "text/html;
charset=utf-8");
+ }
+ // A Superset page that is not the embedded one — what a link in a Markdown
+ // chart can reach. It listens for nothing.
+ if (url.pathname === "/plain") {
+ return send(
+ res,
+ 200,
+ `<!doctype html><meta charset="utf-8"><title>a page that is not
embedded</title>
+ <body style="font:14px system-ui;padding:24px">
+ <h1>Not the embedded page</h1>
+ <p>No switchboard here. Nothing will answer the SDK's handshake.</p>
+ <p><a id="back" href="/embedded/rig-dashboard?page=back">back to the
dashboard</a></p>`,
+ );
+ }
+ if (url.pathname === "/vendor/switchboard.js") {
+ return sendFile(
+ res,
+ join(sdkRoot,
"node_modules/@superset-ui/switchboard/esm/switchboard.js"),
+ "text/javascript",
+ );
+ }
+ return send(res, 404, "not found", "text/plain");
+});
+
+// Rejects, rather than hanging, when either port cannot be bound (typically a
+// previous run still holding it), after closing whichever server did come up.
+export async function start() {
+ const servers = [
+ [hostServer, HOST_PORT],
+ [supersetServer, SUPERSET_PORT],
+ ];
+ const stop = () => {
+ for (const [server] of servers) if (server.listening) server.close();
+ };
+ const outcomes = await Promise.allSettled(
+ servers.map(
+ ([server, port]) =>
+ new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(port, () => {
+ server.off("error", reject);
+ resolve();
+ });
+ }),
+ ),
+ );
+ const failure = outcomes.find((o) => o.status === "rejected");
+ if (failure) {
+ stop();
+ throw new Error(
+ `the rig could not start: ${failure.reason.message} ` +
+ "(set RIG_HOST_PORT / RIG_SUPERSET_PORT to use other ports)",
+ { cause: failure.reason },
+ );
+ }
+ return { hostOrigin, supersetOrigin, stop };
+}
+
+// `node server.mjs` runs the rig for a human; the driver imports `start`.
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ const { hostOrigin: h } = await start();
+ console.log(`test rig up:
+ host app ${h}
+ fake superset ${supersetOrigin}
+open the host app and use the buttons. Ctrl-C to stop.`);
+}