rusackas commented on code in PR #44608: URL: https://github.com/apache/superset/pull/44608#discussion_r4131243719
########## 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: Fixed in 338ead868. `theme`/`tabs`/`unmount` all route through a `hasDashboard()` guard now, same pattern `rigGetActiveTabs` already used. ########## 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: Fixed in 338ead868, the CDP message listener wraps `JSON.parse` in try/catch and drops unparsable frames instead of throwing. ########## 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: Fixed in 338ead868. `launchBrowser()` wraps the post-spawn `connect()` call in try/catch and calls `stop()` before rethrowing, so a handshake failure doesn't orphan the process. ########## 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: Fixed in 338ead868, 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. -- 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]
