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.`);
+}

Reply via email to