rusackas commented on code in PR #44608: URL: https://github.com/apache/superset/pull/44608#discussion_r4098597176
########## superset-embedded-sdk/testrig/drive.mjs: ########## @@ -0,0 +1,573 @@ +/* + * 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) => { + const msg = JSON.parse(event.data); + 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); + }); + } +} + +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", + ]; + const child = spawn(CHROMIUM, args, { stdio: ["ignore", "pipe", "pipe"] }); + 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}`)), + ); + 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, { once: true }); + }); + return { + cdp: new CDP(ws), + stop: () => { + try { + child.kill("SIGKILL"); + } finally { + rmSync(userDataDir, { recursive: true, force: true }); + } + }, + }; +} + +// ------------------------------------------------------------------- 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 }; +} + +async function waitFor(predicate, what, timeoutMs = 15_000, intervalMs = 100) { + const deadline = Date.now() + timeoutMs; + let last; + for (;;) { + last = await predicate(); + if (last) return last; + if (Date.now() > deadline) { + throw new Error(`timed out waiting for ${what}`); + } + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ------------------------------------------------------------------ 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 +function buildIfStale() { + const bundle = join(sdkRoot, "bundle", "index.js"); + 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…"); Review Comment: 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/`. ########## superset-embedded-sdk/testrig/drive.mjs: ########## @@ -0,0 +1,573 @@ +/* + * 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) => { + const msg = JSON.parse(event.data); + 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); + }); + } +} + +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", + ]; + const child = spawn(CHROMIUM, args, { stdio: ["ignore", "pipe", "pipe"] }); + 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}`)), + ); + 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, { once: true }); + }); + return { + cdp: new CDP(ws), + stop: () => { + try { + child.kill("SIGKILL"); + } finally { + rmSync(userDataDir, { recursive: true, force: true }); + } + }, + }; +} + +// ------------------------------------------------------------------- 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 }; +} + +async function waitFor(predicate, what, timeoutMs = 15_000, intervalMs = 100) { + const deadline = Date.now() + timeoutMs; + let last; + for (;;) { + last = await predicate(); + if (last) return last; + if (Date.now() > deadline) { + throw new Error(`timed out waiting for ${what}`); + } + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ------------------------------------------------------------------ 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 +function buildIfStale() { + const bundle = join(sdkRoot, "bundle", "index.js"); + 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…"); + 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(); + const server = await start(); + const browser = await launchBrowser(); + try { Review Comment: 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. ########## superset-embedded-sdk/testrig/server.mjs: ########## @@ -0,0 +1,186 @@ +/* + * 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"); +}); + +export function start() { + return new Promise((resolve) => { + let listening = 0; Review Comment: 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). ########## superset-embedded-sdk/testrig/host.html: ########## @@ -0,0 +1,208 @@ +<!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(); + } + + // Settle the held first fetch, long after the SDK asked for it. + 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"); + }; Review Comment: Confirmed alongside #4 below, same underlying gap. ########## superset-embedded-sdk/testrig/host.html: ########## @@ -0,0 +1,208 @@ +<!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(); + } + + // Settle the held first fetch, long after the SDK asked for it. + 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"); + }; + + 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}`); + }, + ); + }; + + document.getElementById("theme").onclick = () => { + rig.dashboard.setThemeConfig({ token: { colorPrimary: "#ff0066" } }); + rig.dashboard.setThemeMode("dark"); + log("host", "theme pushed"); + }; Review Comment: 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. ########## superset-embedded-sdk/testrig/drive.mjs: ########## @@ -0,0 +1,573 @@ +/* + * 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) => { + const msg = JSON.parse(event.data); Review Comment: 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. ########## superset-embedded-sdk/testrig/drive.mjs: ########## @@ -0,0 +1,573 @@ +/* + * 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) => { + const msg = JSON.parse(event.data); + 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); + }); + } +} + +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", + ]; + const child = spawn(CHROMIUM, args, { stdio: ["ignore", "pipe", "pipe"] }); + 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}`)), + ); + 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, { once: true }); + }); Review Comment: 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. ########## superset-embedded-sdk/testrig/drive.mjs: ########## @@ -0,0 +1,573 @@ +/* + * 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) => { + const msg = JSON.parse(event.data); + 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); + }); + } +} + +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", + ]; + const child = spawn(CHROMIUM, args, { stdio: ["ignore", "pipe", "pipe"] }); + 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}`)), + ); + 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, { once: true }); + }); + return { + cdp: new CDP(ws), + stop: () => { + try { + child.kill("SIGKILL"); + } finally { + rmSync(userDataDir, { recursive: true, force: true }); + } + }, + }; +} + +// ------------------------------------------------------------------- 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 }; +} + +async function waitFor(predicate, what, timeoutMs = 15_000, intervalMs = 100) { + const deadline = Date.now() + timeoutMs; + let last; + for (;;) { + last = await predicate(); + if (last) return last; Review Comment: 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. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
