Skip to content

test(embedded-sdk): cross-document test rig for the navigation fix - #44608

Merged
rusackas merged 4 commits into
apache:masterfrom
chadek:test/embedded-sdk-navigation-rig
Oct 1, 2026
Merged

rusackas merged 4 commits into
apache:masterfrom
chadek:test/embedded-sdk-navigation-rig

Conversation

@chadek

@chadek chadek commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

SUMMARY

Split out of #44470 per rusackas's review so that PR stays just the fix. This is the local test rig referenced in that PR's testing instructions.

superset-embedded-sdk/src/index.test.ts mocks MessageChannel and Switchboard, so it can't exercise the parts of the bug that only exist between two real documents: the port-transfer handshake, what survives a navigation the dashboard makes on its own, and how many guest tokens the host's endpoint is actually asked for.

This rig runs a host app and a stand-in for Superset's embedded page on two origins, loads the built UMD bundle in headless Chromium, and drives it over the real @superset-ui/switchboard. node testrig/drive.mjs runs 39 checks covering:

  • a first embed rendering on exactly one minted token
  • an internal navigation re-authenticating the new document, in order (token before theme)
  • a get still in flight when the user navigates rejecting as PortClosedError instead of hanging, and the same on unmount()
  • a navigation that leaves the embedded page minting no token at all
  • the refresh timer following the current document after a reload
  • both directions of the race between the initial token fetch and a navigation that happens while it's still in flight

Depends on #44468's fix (#44470) to pass. This PR itself carries no fix code, only the test tooling — checked against master as it stands today, drive.mjs fails, reproducing the bug. I ran it locally against fix/embedded-sdk-internal-navigation (i.e. this branch's files, on top of that fix) and got 39/39 passing; the README documents running it against git show HEAD~1:... of src/index.ts to watch the checks fail for the same reason. Recommend merging #44470 first, or reviewing this diff on its own and trusting the local run.

Not wired into CI (.github/workflows/embedded-sdk-test.yml only runs npm test/npm run build): it needs a Chromium binary on PATH, and the unit tests already gate the merge. It's meant as a manual/local tool for exercising the cross-document behavior when touching this handshake again.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A — no product code change, test tooling only.

TESTING INSTRUCTIONS

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. Run it on top of #44470's branch to see 39/39 pass, or on master alone to watch it reproduce the bug.

To poke at it by hand: node testrig/server.mjs, then open http://localhost:8100. See testrig/README.md for the query params that reach the two token-fetch races.

ADDITIONAL INFORMATION

🤖 Generated with Claude Code

superset-embedded-sdk/index.test.ts mocks MessageChannel and Switchboard,
so it cannot exercise the parts of apache#44470 that only exist between two
real documents: the port-transfer handshake, what survives a navigation
the dashboard makes on its own, and how many guest tokens the host's
endpoint is actually asked for.

This rig loads the built UMD bundle in headless Chromium against a host
app and a stand-in Superset page on two origins, over the real
@superset-ui/switchboard. `node testrig/drive.mjs` runs 39 checks
covering the internal-navigation reload, the PortClosedError rejections,
the refresh-timer handoff, and both directions of the race between the
initial token fetch and a navigation racing it.

Not wired into CI: it needs a Chromium binary, and apache#44470's own unit
tests already gate the merge. Kept as a manual/local tool per
rusackas's review, split out of apache#44470 to keep that PR to just the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread superset-embedded-sdk/testrig/drive.mjs Outdated
Comment on lines +530 to +536
const newestSrc = Math.max(
...readdirSync(join(sdkRoot, "src")).map((f) =>
statSync(join(sdkRoot, "src", f)).mtimeMs,
),
);
if (existsSync(bundle) && statSync(bundle).mtimeMs > newestSrc) return;
console.log("building the sdk bundle…");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The stale check ignores webpack.config.js and dependency changes, so the rig can serve an older SDK bundle while reporting results for current source code.

Assessment: 🟠 Major · 🔁 Occurrence: Rarely · 🏷️ Possible bug

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset-embedded-sdk/testrig/drive.mjs
**Line:** 530:536
**Comment:**
	*Possible Bug: The stale check ignores `webpack.config.js` and dependency changes, so the rig can serve an older SDK bundle while reporting results for current source code.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, checked buildIfStale() directly: it only diffs src/* mtimes against the built bundle, never webpack.config.js. Real, lowest priority of the bunch given how rarely that file changes relative to src/.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 338ead8. buildIfStale() now checks BUILD_INPUTS (webpack.config.js, babel.config.js, tsconfig.json, package.json, package-lock.json) alongside src/.

Comment thread superset-embedded-sdk/testrig/drive.mjs Outdated
Comment on lines +551 to +553
const server = await start();
const browser = await launchBrowser();
try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: If launchBrowser() fails, execution never enters try, so the HTTP servers and Chromium temporary directory remain active and can break later runs.

Assessment: 🟠 Major · 🔁 Occurrence: Rarely · 🏷️ Resource leak

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset-embedded-sdk/testrig/drive.mjs
**Line:** 551:553
**Comment:**
	*Resource Leak: If `launchBrowser()` fails, execution never enters `try`, so the HTTP servers and Chromium temporary directory remain active and can break later runs.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. main()'s try/finally only wraps the code after both await start() and await launchBrowser() succeed, so a rejection from either one skips server.stop()/browser.stop() entirely. Same root cause as the orphaned-chromium finding below, worth fixing together.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 338ead8. server and browser are both set up inside main()'s try now, so the finally cleans up whichever one did come up even if the other fails.

Comment on lines +158 to +160
export function start() {
return new Promise((resolve) => {
let listening = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: start() never rejects when either port is unavailable, so a bind error becomes an unhandled server error instead of a controlled startup failure.

Assessment: 🟠 Major · 🔁 Occurrence: Sometimes · 🏷️ Possible bug

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset-embedded-sdk/testrig/server.mjs
**Line:** 158:160
**Comment:**
	*Possible Bug: `start()` never rejects when either port is unavailable, so a bind error becomes an unhandled server error instead of a controlled startup failure.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. start()'s new Promise((resolve) => {...}) has no reject path at all, on a port-bind failure listen()'s callback never fires (that's a 'listening' callback, not a general completion callback), so the promise hangs forever, and the server's own unhandled 'error' event throws separately. Real, and plausible to hit in practice (re-running the rig while a previous instance is still bound to the port).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ CodeAnt verified this suggestion was addressed in subsequent commits and marked this thread resolved as of 338ead8.

start() now wraps each server’s listen() in a rejecting promise and waits with Promise.allSettled(). On bind failure it closes any listening server and throws a controlled startup error.

If that's not right, unresolve this thread and CodeAnt will leave it open.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ CodeAnt verified this suggestion was addressed in subsequent commits and marked this thread resolved as of 219ca1a.

start() now wraps each server’s listen() in a rejecting promise and waits with Promise.allSettled(). On bind failure it closes any listening server and throws a controlled startup error.

If that's not right, unresolve this thread and CodeAnt will leave it open.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ CodeAnt verified this suggestion was addressed in subsequent commits and marked this thread resolved as of a017548.

start() now wraps each server’s listen() in a rejecting promise and waits with Promise.allSettled(). On bind failure it closes any listening server and throws a controlled startup error.

If that's not right, unresolve this thread and CodeAnt will leave it open.

@bito-code-review bito-code-review Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Agent Run #a69ba8

Actionable Suggestions - 5
  • superset-embedded-sdk/testrig/host.html - 2
  • superset-embedded-sdk/testrig/drive.mjs - 3
Additional Suggestions - 16
  • superset-embedded-sdk/testrig/host.html - 2
    • Unguarded iframe deref · Line 198-203
      `rigNavigateGuest` calls `document.querySelector("#mount iframe").contentWindow` without checking the query result. Before `embedDashboard` mounts the iframe (or after unmount), `querySelector` returns null and `.contentWindow` throws TypeError.
    • failnth NaN silent no-op · Line 95-95
      `Number(search.get("failnth") || 0)` yields NaN for a non-numeric `failnth` value. Since `call === NaN` is always false, the failnth feature silently no-ops on malformed input instead of rejecting call N.
  • superset-embedded-sdk/testrig/drive.mjs - 12
    • Magic CDP timeout literal · Line 99-99
      The 30s CDP command timeout is a bare literal; `waitFor`/`expect` in this file already name their timeouts (`timeoutMs`), so a named constant (e.g. `CDP_TIMEOUT_MS`) would match the file's own convention and keep the rig's timing budget tunable in one place.
    • Magic startup timeout literal · Line 129-129
      The 20s browser-startup timeout is a bare literal encoding an operational threshold; naming it (e.g. `BROWSER_STARTUP_TIMEOUT_MS`) keeps both rig timeouts (`CDP.send` uses 30s) discoverable and tunable in one place.
    • Unused sessionId in return · Line 190-190
      All four call sites — `mainRun` (234), `refreshRun` (368), `staleInitialFetchRun` (412), `supersededTokenRun` (471) — destructure only `{ evaluate }`; nothing reads the returned `sessionId`. Drop it from the return object so the contract doesn't advertise a consumer that doesn't exist.
    • Sleep idiom duplicated · Line 202-202
      `waitFor` builds `new Promise((r) => setTimeout(r, intervalMs))` (202) — the exact idiom `sleep` (206) wraps and the rest of the file uses. Reuse `sleep(intervalMs)`; `waitFor` only runs after module evaluation, so the `const` binding is initialized by call time.
    • Tally lost on run crash · Line 570-573
      When any run throws (e.g. `openPage`'s `waitFor` timing out on host load at drive.mjs:186-189, called without `expect`'s catch), the `finally` stops browser/server but the tally built from `results` at 563-567 is skipped and `.catch` exits with only the error. This contradicts the rig's stated aim (comment at lines 56-58) of reporting per-scenario results instead of stopping at the first failure. Print the accumulated tally in the catch before exiting.
    • Delayed-retry blind spot · Line 457-461
      This check observes only 500ms after `failFirstToken()`, but the SDK's own failure retry (`DEFAULT_TOKEN_REFRESH_RETRY_MS` = 10s, used by `refreshGuestToken` in `src/index.ts`) fires at 10s — after `unmount()` ends the observation. If the stale rejection is ever routed into that retry path, the mint lands unobserved and "mints nothing further" still passes. Consider waiting past the retry window before recounting.
    • Verbose listener never removed · Line 157-166
      Each `openPage` call pushes a listener into `cdp.listeners` (drive.mjs:158) and nothing ever removes it — `listeners` is only initialized (76), iterated (85), and pushed (158). One closure accumulates per opened page for the process lifetime; only the `sessionId` guard keeps stale ones inert. Return a dispose from `openPage` or remove the listener on target close to keep the set bounded.
    • Inconsistent helper naming · Line 222-222
      In `makeHelpers`, every zero-arg probe is a bare noun or verb — `events`, `errors`, `embedState`, `tokensMinted`, `hasIframe` — except `getActiveTabs` (222). Aligning it (e.g. `activeTabs`) keeps the helper table uniform; the rename must also update its call site at drive.mjs:451.
    • Build errors silenced · Line 539-539
      `buildIfStale` runs `npx webpack` with `stdio: "ignore"` when not verbose, so a failed build throws "Command failed: npx webpack…" carrying none of webpack's diagnostics (verified on Node 20.12.1: `error.stderr` is null). The rig dies with no clue why. `stdio: "pipe"` keeps successful builds equally quiet while the thrown error carries the build output.
    • Unused import binding · Line 28-28
      `HOST_PORT` is imported from `./server.mjs` but never referenced anywhere in `drive.mjs` (verified by grep: only occurrence is the import itself; `start()` at line 551 is the sole use of the module). Unused bindings in an import statement are dead code and mislead readers into thinking the driver depends on the rig's port constant. Drop `HOST_PORT` from the import list.
    • Duplicated scenario setup · Line 469-483
      `supersededTokenRun` repeats `staleInitialFetchRun`'s rigging verbatim — `/reset`, `openPage(...?slowfirst=1)`, `click("embed")`, the `heldFirstFetch` and `eventsOn(1, "started")` waits, then `navigateGuest` (469-483 vs 411-429). If the ritual changes (event name, query params), both scenarios need synchronized edits. A shared setup helper keeps the two race scenarios from drifting while preserving their narration.
    • Missing state reset in mainRun · Line 243-247
      mainRun asserts absolute minted counts (`h.tokensMinted() === 1` at 245, `=== 2` at 264, `=== beforeStray + 1` at 335) but, unlike `refreshRun`, `staleInitialFetchRun` and `supersededTokenRun` (lines 367, 411, 469), never calls `fetch(`${hostOrigin}/reset`)` first. It is only correct because `main()` happens to run it first on a fresh server; a leading reset would make it self-contained like its siblings.
  • superset-embedded-sdk/testrig/embedded.html - 2
    • base64url decoded via atob · Line 80-80
      `server.mjs` mints the token with Node's `Buffer.toString("base64url")`, but this line decodes with `atob`, which is standard base64 and throws `InvalidCharacterError` on `-`/`_`. It only works today because the fixed claims happen to encode without those chars; any payload change (e.g. a longer `serial` or a non-ASCII `username`) breaks the rig. Decode base64url explicitly.
    • theme mode system unhandled · Line 104-104
      The real `setThemeMode` in `superset-frontend/src/embedded/index.tsx` accepts `default | dark | system` and maps each to a `ThemeMode`. This rig only toggles on `mode === 'dark'`, so a `system` request silently removes the dark class and returns `{success:true}`. If a test ever exercises `system`, the rig reports success for a mode it did not apply.
Review Details
  • Files reviewed - 4 · Commit Range: a22ce65..a22ce65
    • superset-embedded-sdk/testrig/drive.mjs
    • superset-embedded-sdk/testrig/embedded.html
    • superset-embedded-sdk/testrig/host.html
    • superset-embedded-sdk/testrig/server.mjs
  • Files skipped - 1
    • superset-embedded-sdk/testrig/README.md - Reason: Filter setting
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Comment on lines +121 to +129
window.rigReleaseFirstToken = async () => {
const token = await mintToken();
rig.heldFirstFetch.resolve(token);
log("host", "released the held fetchGuestToken() #1");
};
window.rigFailFirstToken = () => {
rig.heldFirstFetch.reject(new Error("rig: host token endpoint is down"));
log("error", "failed the held fetchGuestToken() #1");
};

ghost Sep 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unguarded heldFirstFetch deref

rigReleaseFirstToken/rigFailFirstToken dereference rig.heldFirstFetch, which is null unless ?slowfirst=1 and call 1 is still held. Calling them otherwise throws TypeError. Also, if mintToken() rejects in rigReleaseFirstToken, the held promise is never settled and the SDK's fetchGuestToken hangs forever.

Code suggestion
Check the AI-generated fix before applying
Suggested change
window.rigReleaseFirstToken = async () => {
const token = await mintToken();
rig.heldFirstFetch.resolve(token);
log("host", "released the held fetchGuestToken() #1");
};
window.rigFailFirstToken = () => {
rig.heldFirstFetch.reject(new Error("rig: host token endpoint is down"));
log("error", "failed the held fetchGuestToken() #1");
};
window.rigReleaseFirstToken = async () => {
if (!rig.heldFirstFetch) return;
const token = await mintToken();
rig.heldFirstFetch.resolve(token);
log("host", "released the held fetchGuestToken() #1");
};
window.rigFailFirstToken = () => {
if (!rig.heldFirstFetch) return;
rig.heldFirstFetch.reject(new Error("rig: host token endpoint is down"));
log("error", "failed the held fetchGuestToken() #1");
};

Code Review Run #a69ba8


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

ghost Sep 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed alongside #4 below, same underlying gap.

ghost Sep 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggestion is correct and should be applied. The current implementation of rigReleaseFirstToken and rigFailFirstToken lacks checks for rig.heldFirstFetch, which causes a TypeError if called when no fetch is held. Additionally, the proposed fix ensures that if mintToken() fails, the promise is properly handled, preventing the SDK's fetchGuestToken from hanging indefinitely.

superset-embedded-sdk/testrig/host.html

window.rigReleaseFirstToken = async () => {
    if (!rig.heldFirstFetch) return;
    const token = await mintToken();
    rig.heldFirstFetch.resolve(token);
    log("host", "released the held fetchGuestToken() #1");
  };
  window.rigFailFirstToken = () => {
    if (!rig.heldFirstFetch) return;
    rig.heldFirstFetch.reject(new Error("rig: host token endpoint is down"));
    log("error", "failed the held fetchGuestToken() #1");
  };

ghost Sep 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 338ead8 with a takeHeldFirstFetch() helper that guards both callers, and rigReleaseFirstToken now rejects the held promise if mintToken() throws instead of leaving it hanging.

ghost Sep 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggestion is correct and addresses the identified issues. By adding checks for rig.heldFirstFetch, it prevents potential TypeErrors when the functions are called unexpectedly, and by properly handling the promise rejection if mintToken() fails, it ensures the SDK does not hang indefinitely.

superset-embedded-sdk/testrig/host.html

window.rigReleaseFirstToken = async () => {
    if (!rig.heldFirstFetch) return;
    const token = await mintToken();
    rig.heldFirstFetch.resolve(token);
    log("host", "released the held fetchGuestToken() #1");
  };
  window.rigFailFirstToken = () => {
    if (!rig.heldFirstFetch) return;
    rig.heldFirstFetch.reject(new Error("rig: host token endpoint is down"));
    log("error", "failed the held fetchGuestToken() #1");
  };

Comment on lines +160 to +164
document.getElementById("theme").onclick = () => {
rig.dashboard.setThemeConfig({ token: { colorPrimary: "#ff0066" } });
rig.dashboard.setThemeMode("dark");
log("host", "theme pushed");
};

ghost Sep 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unguarded null dashboard deref

theme, tabs, and unmount handlers dereference rig.dashboard without a null guard. rig.dashboard stays null until embedDashboard resolves and remains null if it rejects, so clicking these before resolution (or after a rejection) throws a TypeError. rigGetActiveTabs already guards this; apply the same check here.

Code suggestion
Check the AI-generated fix before applying
Suggested change
document.getElementById("theme").onclick = () => {
rig.dashboard.setThemeConfig({ token: { colorPrimary: "#ff0066" } });
rig.dashboard.setThemeMode("dark");
log("host", "theme pushed");
};
document.getElementById("theme").onclick = () => {
if (!rig.dashboard) {
log("error", "theme: no dashboard yet");
return;
}
rig.dashboard.setThemeConfig({ token: { colorPrimary: "#ff0066" } });
rig.dashboard.setThemeMode("dark");
log("host", "theme pushed");
};

Code Review Run #a69ba8


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

ghost Sep 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. theme/tabs/unmount all deref rig.dashboard directly with no guard, while rigGetActiveTabs (the one the driver script actually calls) already does if (!rig.dashboard) return .... Only reachable in --headed mode by a human clicking ahead of embedDashboard() resolving, so it's a console error in a manual debugging session rather than a silent wrong result, lower stakes than the drive.mjs findings but still worth the one-line guard for consistency.

ghost Sep 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggestion is appropriate and improves the code by preventing potential TypeErrors when the dashboard is not yet initialized. Applying this guard ensures consistency with other parts of the driver script that already implement this check.

superset-embedded-sdk/testrig/host.html

document.getElementById("theme").onclick = () => {
    if (!rig.dashboard) {
      log("error", "theme: no dashboard yet");
      return;
    }
    rig.dashboard.setThemeConfig({ token: { colorPrimary: "#ff0066" } });
    rig.dashboard.setThemeMode("dark");
    log("host", "theme pushed");
  };

ghost Sep 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 338ead8. theme/tabs/unmount all route through a hasDashboard() guard now, same pattern rigGetActiveTabs already used.

ghost Sep 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggestion is appropriate and improves the code by preventing potential TypeErrors when the dashboard is not yet initialized. Applying this guard ensures consistency with other parts of the driver script that already implement this check.

superset-embedded-sdk/testrig/host.html

document.getElementById("theme").onclick = () => {
    if (!rig.dashboard) {
      log("error", "theme: no dashboard yet");
      return;
    }
    rig.dashboard.setThemeConfig({ token: { colorPrimary: "#ff0066" } });
    rig.dashboard.setThemeMode("dark");
    log("host", "theme pushed");
  };

Comment thread superset-embedded-sdk/testrig/drive.mjs Outdated
this.pending = new Map();
this.listeners = [];
ws.addEventListener("message", (event) => {
const msg = JSON.parse(event.data);

ghost Sep 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CWE-755: Unguarded JSON.parse crash

JSON.parse(event.data) runs inside the socket's message listener with no guard, so any non-JSON or binary frame throws inside the listener; in Node an exception escaping an EventTarget listener surfaces as an uncaught exception and kills the rig process, losing every check result. Wrap the parse in try/catch and skip malformed frames. (CWE-755)

Code Review Run #a69ba8


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

ghost Sep 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, no try/catch around JSON.parse(event.data) in the CDP message listener. An exception thrown inside a ws message listener isn't caught anywhere upstream here, real crash risk even if CDP frames are rarely malformed in practice.

ghost Sep 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggestion to wrap JSON.parse(event.data) in a try/catch block is correct and highly recommended. In the current implementation, an unhandled exception during parsing would propagate to the event listener, potentially causing an uncaught exception that terminates the process. Applying this guard ensures the rig remains stable even if it receives malformed or non-JSON frames.

superset-embedded-sdk/testrig/drive.mjs

ws.addEventListener("message", (event) => {
      try {
        const msg = JSON.parse(event.data);
        // ... handle message
      } catch (e) {
        console.error("Failed to parse CDP message:", e);
      }

ghost Sep 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 338ead8, the CDP message listener wraps JSON.parse in try/catch and drops unparsable frames instead of throwing.

ghost Sep 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggestion to wrap JSON.parse(event.data) in a try/catch block is correct and highly recommended. In the current implementation, an unhandled exception during parsing would propagate to the event listener, potentially causing an uncaught exception that terminates the process. Applying this guard ensures the rig remains stable even if it receives malformed or non-JSON frames.

superset-embedded-sdk/testrig/drive.mjs

ws.addEventListener("message", (event) => {
      try {
        const msg = JSON.parse(event.data);
        // ... handle message
      } catch (e) {
        console.error("Failed to parse CDP message:", e);
      }

Comment on lines +131 to +135
const ws = new WebSocket(wsUrl);
await new Promise((resolve, reject) => {
ws.addEventListener("open", resolve, { once: true });
ws.addEventListener("error", reject, { once: true });
});

ghost Sep 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orphaned chromium on ws failure

Between spawn (line 116) and the return, nothing cleans up if the WebSocket handshake rejects: the error propagates out of launchBrowser before main's try/finally, so stop() never runs and the SIGKILL/rmSync cleanup is skipped, leaving an orphaned chromium process and a temp profile dir. Wrap the post-spawn body in try/catch that kills the child and removes the dir before rethrowing.

Code Review Run #a69ba8


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

ghost Sep 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed via the same code path as the codeant finding on this file: if the WebSocket 'error' listener fires during the handshake, the already-spawned chromium child process has no cleanup on that path, only stop() (returned after success) kills it. Same root cause as the try/finally gap in main(), worth fixing as one change.

ghost Sep 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggestion to wrap the post-spawn logic in a try/catch block is correct. Currently, if the WebSocket handshake fails, the error propagates out of launchBrowser before the cleanup logic in main can execute, leaving the Chromium process orphaned and the temporary directory unremoved. Applying this change ensures that the child process is killed and the directory is cleaned up regardless of whether the handshake succeeds or fails.

superset-embedded-sdk/testrig/drive.mjs

const ws = new WebSocket(wsUrl);
  try {
    await new Promise((resolve, reject) => {
      ws.addEventListener("open", resolve, { once: true });
      ws.addEventListener("error", reject, { once: true });
    });
  } catch (err) {
    child.kill();
    rmSync(userDataDir, { recursive: true, force: true });
    throw err;
  }

ghost Sep 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 338ead8. launchBrowser() wraps the post-spawn connect() call in try/catch and calls stop() before rethrowing, so a handshake failure doesn't orphan the process.

ghost Sep 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix implemented in the referenced commit correctly addresses the issue of orphaned Chromium processes. By wrapping the post-spawn WebSocket handshake in a try/catch block, the code ensures that if the handshake fails, the child process is terminated and the temporary directory is removed before the error propagates, preventing resource leaks.

superset-embedded-sdk/testrig/drive.mjs

const ws = new WebSocket(wsUrl);
  try {
    await new Promise((resolve, reject) => {
      ws.addEventListener("open", resolve, { once: true });
      ws.addEventListener("error", reject, { once: true });
    });
  } catch (err) {
    child.kill();
    rmSync(userDataDir, { recursive: true, force: true });
    throw err;
  }

Comment thread superset-embedded-sdk/testrig/drive.mjs Outdated
Comment on lines +196 to +198
for (;;) {
last = await predicate();
if (last) return last;

ghost Sep 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Predicate rejection crashes run

waitFor (drive.mjs:196-203) lets a rejected predicate escape, and openPage's readiness poll calls evaluate, whose cdp.send rejects on CDP errors (drive.mjs:82). A navigation destroying the execution context mid-poll rejects evaluate and crashes the whole run instead of retrying until the 15s deadline. expect catches predicate errors; openPage's direct call does not. Consider tolerating predicate rejections until the deadline.

Code Review Run #a69ba8


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

ghost Sep 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and this is the one I'd weight most heavily: openPage()'s own waitFor(() => evaluate(...), "the host app to load") call isn't wrapped the way expect() wraps its waitFor calls, so a predicate rejection propagates straight out. evaluate() rejects on a CDP-level error, exactly what happens when a navigation destroys the execution context mid-poll, which is precisely the race this rig exists to test. As written, hitting that race at the wrong moment can crash the whole 39-check run instead of retrying.

ghost Sep 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reviewer is correct. In openPage(), the waitFor call is invoked directly without the expect() wrapper, meaning any rejection from the evaluate() predicate—such as a CDP error caused by a navigation destroying the execution context—will propagate and terminate the entire test run. Wrapping this call in a try-catch block or using a similar retry mechanism to the one implemented in expect() would allow the test to tolerate these transient failures until the timeout is reached.

superset-embedded-sdk/testrig/drive.mjs

// 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",
  );

ghost Sep 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 338ead8, actually at the root: waitFor() itself now catches predicate rejections and keeps retrying until the deadline, so openPage() didn't need an expect() wrapper after all.

ghost Sep 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user is correct. Since waitFor() now handles predicate rejections by retrying until the deadline, the expect() wrapper is no longer required for openPage() to avoid crashing the test run on transient CDP errors.

ghost left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heya @chadek, nice rig, and appreciate splitting this out of #44470 so that PR stays focused. Went through all 8 bot findings here (both bito and codeant) and traced each one against the actual code rather than just trusting the summaries, all 8 turned out to be real, replied on each thread with what I found.

Two of them are the ones I'd actually block on, since they undercut the rig's own job:

openPage()'s own waitFor(() => evaluate(...), "the host app to load") call isn't wrapped the way expect() wraps its waitFor calls, so a predicate rejection propagates straight out instead of retrying. evaluate() rejects on a CDP-level error, which is exactly what happens when a navigation destroys the execution context mid-poll, the precise race this rig exists to reproduce. Hit that at the wrong moment and the whole 39-check run crashes instead of retrying to the 15s deadline.

The unguarded JSON.parse(event.data) in the CDP message listener has the same shape: rare in practice, but an exception thrown inside a ws message listener here isn't caught anywhere upstream, so it takes the whole process down mid-run with no diagnostic.

The rest (resource leak on a launchBrowser()/handshake failure, server.mjs's start() never rejecting on a port-bind failure, the unguarded rig.dashboard derefs in host.html's manual click handlers, and buildIfStale() not watching webpack.config.js) are real too but lower stakes, mostly annoyances for whoever's running this locally rather than things that would produce a false pass/fail. Your call whether those are worth bundling into this PR or a fast follow.

Given this is dev-only tooling (not wired into CI, not shipping anywhere), I don't think this needs the same bar as product code, but a test rig that can crash on the exact race it's built to catch is worth tightening before anyone leans on its results.

Addresses review on apache#44608:

- waitFor treats a rejected predicate as "not yet" and keeps polling to
  the deadline, so a navigation that destroys the execution context
  mid-poll (the race the rig reproduces) no longer crashes the run
- the CDP message listener drops unparsable frames instead of throwing
  out of the listener
- launchBrowser cleans up chromium and its profile if the devtools
  handshake fails, and main() starts the servers and the browser inside
  its try so whichever came up is stopped
- chromium runs in its own process group and is stopped as a group,
  waiting for it to exit before removing the profile: its helper
  processes otherwise wrote back into the profile after removal, leaving
  a temp dir behind on every run; SIGINT/SIGTERM stop it too
- server start() rejects on a port-bind failure instead of hanging
- host.html guards rig.dashboard and rig.heldFirstFetch, settles the
  held fetch only once, and rejects it if minting the token fails
- buildIfStale also watches the build config and dependency manifests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@netlify

ghost commented Sep 25, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit a017548
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6abc0316a2822b0008ece2ca
😎 Deploy Preview https://deploy-preview-44608--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@chadek

ghost commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for tracing each of these. All 8 are fixed in 338ead8, covering the two you'd block on and the four lower-stakes ones.

While re-running the rig to check the fixes I found one more leak, in the same area as the launch/handshake one: even a passing run left a /tmp/embedded-sdk-rig-* profile behind. stop() SIGKILLed only the main Chromium process, and its helper processes kept writing into the profile after it was removed. Chromium now runs in its own process group and is stopped as a group, and the profile is only deleted once it has exited. SIGINT/SIGTERM stop it too, since Ctrl-C no longer reaches a separate process group.

Checked locally with real Chromium:

  • 39/39 on top of fix(embedded-sdk): re-authenticate the iframe after a navigation internal to the dashboard #44470
  • Against master's src/, 18/37 with the failures reported and a tally at the end. The rig still catches the bug; retrying errors didn't hide it.
  • A navigation that destroys the page mid-poll: the poll retries and recovers.
  • Port 8100 already taken: clean error and exit 1.
  • A fake Chromium whose connection fails, a nonexistent binary, and Ctrl-C mid-run: all clean up, with no leftover process, temp directory or bound port.

@bito-code-review

ghost commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #dde8e6

Actionable Suggestions - 0
Review Details
  • Files reviewed - 3 · Commit Range: a22ce65..338ead8
    • superset-embedded-sdk/testrig/drive.mjs
    • superset-embedded-sdk/testrig/host.html
    • superset-embedded-sdk/testrig/server.mjs
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@chadek
chadek requested a review from rusackas September 28, 2026 12:34

ghost left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling all of these in 338ead8, @chadek. Went back through each thread and the two I was actually blocking on, the waitFor rejection escaping openPage() and the unguarded JSON.parse in the CDP listener, are both fixed now. Rest of the batch landed too. LGTM!

@rusackas
rusackas merged commit 90fc473 into apache:master Oct 1, 2026
@chadek

ghost commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for your attention on this @rusackas, it will really help us out when next release will be out. Any idea of an approximated timeline for the next release date target?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Embedded dashboard goes blank after a navigation internal to the dashboard

2 participants