diff --git a/superset-frontend/playwright.config.ts b/superset-frontend/playwright.config.ts
index 9edef2176c8..da2bdbc1fb2 100644
--- a/superset-frontend/playwright.config.ts
+++ b/superset-frontend/playwright.config.ts
@@ -95,6 +95,7 @@ export default defineConfig({
testIgnore: [
'**/tests/auth/**/*.spec.ts',
'**/tests/sqllab/**/*.spec.ts',
+ '**/tests/embedded/**/*.spec.ts',
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
],
use: {
@@ -132,6 +133,18 @@ export default defineConfig({
// No storageState = clean browser with no cached cookies
},
},
+ {
+ // Embedded dashboard tests - validates the full embedding flow:
+ // external app -> SDK -> iframe -> guest token -> dashboard render
+ name: 'chromium-embedded',
+ testMatch: '**/tests/embedded/**/*.spec.ts',
+ use: {
+ browserName: 'chromium',
+ testIdAttribute: 'data-test',
+ // Uses admin auth for API calls to configure embedding and get guest tokens
+ storageState: 'playwright/.auth/user.json',
+ },
+ },
],
// Web server setup - disabled in CI (Flask started separately in workflow)
diff --git a/superset-frontend/playwright/embedded-app/index.html b/superset-frontend/playwright/embedded-app/index.html
new file mode 100644
index 00000000000..ef58a82ded0
--- /dev/null
+++ b/superset-frontend/playwright/embedded-app/index.html
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+ Embedded Dashboard Test App
+
+
+
+ Initializing embedded dashboard...
+
+
+
+
+
+
+
diff --git a/superset-frontend/playwright/helpers/api/dashboard.ts b/superset-frontend/playwright/helpers/api/dashboard.ts
index 2eb8d092c0d..f8141f57c40 100644
--- a/superset-frontend/playwright/helpers/api/dashboard.ts
+++ b/superset-frontend/playwright/helpers/api/dashboard.ts
@@ -132,26 +132,14 @@ export interface DashboardResult {
published?: boolean;
}
-/**
- * Get a dashboard by its title
- * @param page - Playwright page instance (provides authentication context)
- * @param title - The dashboard_title to search for
- * @returns Dashboard object if found, null if not found
- */
-export async function getDashboardByName(
+async function getDashboardByFilter(
page: Page,
- title: string,
+ col: 'dashboard_title' | 'slug',
+ value: string,
): Promise {
- const filter = {
- filters: [
- {
- col: 'dashboard_title',
- opr: 'eq',
- value: title,
- },
- ],
- };
- const queryParam = rison.encode(filter);
+ const queryParam = rison.encode({
+ filters: [{ col, opr: 'eq', value }],
+ });
const response = await apiGet(
page,
`${ENDPOINTS.DASHBOARD}?q=${queryParam}`,
@@ -169,3 +157,29 @@ export async function getDashboardByName(
return null;
}
+
+/**
+ * Get a dashboard by its title
+ * @param page - Playwright page instance (provides authentication context)
+ * @param title - The dashboard_title to search for
+ * @returns Dashboard object if found, null if not found
+ */
+export async function getDashboardByName(
+ page: Page,
+ title: string,
+): Promise {
+ return getDashboardByFilter(page, 'dashboard_title', title);
+}
+
+/**
+ * Get a dashboard by its slug
+ * @param page - Playwright page instance (provides authentication context)
+ * @param slug - The slug to search for
+ * @returns Dashboard object if found, null if not found
+ */
+export async function getDashboardBySlug(
+ page: Page,
+ slug: string,
+): Promise {
+ return getDashboardByFilter(page, 'slug', slug);
+}
diff --git a/superset-frontend/playwright/helpers/api/embedded.ts b/superset-frontend/playwright/helpers/api/embedded.ts
new file mode 100644
index 00000000000..e9e40ffb4c5
--- /dev/null
+++ b/superset-frontend/playwright/helpers/api/embedded.ts
@@ -0,0 +1,113 @@
+/**
+ * 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.
+ */
+
+import { Page } from '@playwright/test';
+import { apiPost, apiPut } from './requests';
+import { ENDPOINTS as DASHBOARD_ENDPOINTS } from './dashboard';
+
+export const ENDPOINTS = {
+ SECURITY_LOGIN: 'api/v1/security/login',
+ GUEST_TOKEN: 'api/v1/security/guest_token/',
+} as const;
+
+export interface EmbeddedConfig {
+ uuid: string;
+ allowed_domains: string[];
+ dashboard_id: number;
+}
+
+/**
+ * Enable embedding on a dashboard and return the embedded UUID.
+ * Uses PUT (upsert) to preserve UUID across repeated calls.
+ * @param page - Playwright page instance (provides authentication context)
+ * @param dashboardIdOrSlug - Numeric dashboard id or slug
+ * @param allowedDomains - Domains allowed to embed; empty array allows all
+ * @returns Embedded config with UUID, allowed_domains, and dashboard_id
+ */
+export async function apiEnableEmbedding(
+ page: Page,
+ dashboardIdOrSlug: number | string,
+ allowedDomains: string[] = [],
+): Promise {
+ const response = await apiPut(
+ page,
+ `${DASHBOARD_ENDPOINTS.DASHBOARD}${dashboardIdOrSlug}/embedded`,
+ { allowed_domains: allowedDomains },
+ );
+ const body = await response.json();
+ return body.result as EmbeddedConfig;
+}
+
+/**
+ * Get a guest token for an embedded dashboard.
+ * Uses the admin login flow (login → access_token → guest_token).
+ * @param page - Playwright page instance (used for request context)
+ * @param dashboardId - Dashboard id to grant access to
+ * @param options - Optional login credentials and RLS rules
+ * @returns Signed guest token string
+ */
+export async function getGuestToken(
+ page: Page,
+ dashboardId: number | string,
+ options?: {
+ username?: string;
+ password?: string;
+ rls?: Array<{ dataset: number; clause: string }>;
+ },
+): Promise {
+ const username = options?.username ?? 'admin';
+ const password = options?.password ?? 'general';
+ const rls = options?.rls ?? [];
+
+ // Step 1: Login to get access token
+ const loginResponse = await apiPost(
+ page,
+ ENDPOINTS.SECURITY_LOGIN,
+ {
+ username,
+ password,
+ provider: 'db',
+ refresh: true,
+ },
+ { allowMissingCsrf: true },
+ );
+ const loginBody = await loginResponse.json();
+ const accessToken = loginBody.access_token;
+
+ // Step 2: Fetch guest token using the access token.
+ // Uses raw page.request.post() (not apiPost) because the guest token endpoint
+ // requires a JWT Bearer token rather than session+CSRF auth.
+ const guestResponse = await page.request.post(ENDPOINTS.GUEST_TOKEN, {
+ data: {
+ user: {
+ username: 'embedded_test_user',
+ first_name: 'Embedded',
+ last_name: 'TestUser',
+ },
+ resources: [{ type: 'dashboard', id: String(dashboardId) }],
+ rls,
+ },
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ const guestBody = await guestResponse.json();
+ return guestBody.token;
+}
diff --git a/superset-frontend/playwright/pages/EmbeddedPage.ts b/superset-frontend/playwright/pages/EmbeddedPage.ts
new file mode 100644
index 00000000000..438c0a7b4f0
--- /dev/null
+++ b/superset-frontend/playwright/pages/EmbeddedPage.ts
@@ -0,0 +1,140 @@
+/**
+ * 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.
+ */
+
+import { Page, FrameLocator } from '@playwright/test';
+import { EMBEDDED } from '../utils/constants';
+
+/**
+ * Page object for the embedded dashboard test app.
+ *
+ * The test app runs on a separate origin (localhost:9000) and uses the
+ * @superset-ui/embedded-sdk to render a Superset dashboard in an iframe.
+ * Playwright's page.exposeFunction() bridges the guest token from Node.js
+ * into the browser page.
+ */
+export class EmbeddedPage {
+ private readonly page: Page;
+
+ private static readonly SELECTORS = {
+ CONTAINER: '[data-test="embedded-container"]',
+ IFRAME: 'iframe[title="Embedded Dashboard"]',
+ STATUS: '#status',
+ ERROR: '#error',
+ } as const;
+
+ constructor(page: Page) {
+ this.page = page;
+ }
+
+ /**
+ * Set up the guest token bridge before navigating.
+ * Must be called BEFORE goto() since embedDashboard() calls fetchGuestToken
+ * immediately on page load.
+ */
+ async exposeTokenFetcher(tokenFn: () => Promise): Promise {
+ await this.page.exposeFunction('__fetchGuestToken', tokenFn);
+ }
+
+ /**
+ * Navigate to the embedded test app with the given parameters.
+ */
+ async goto(params: {
+ uuid: string;
+ supersetDomain: string;
+ hideTitle?: boolean;
+ hideTab?: boolean;
+ hideChartControls?: boolean;
+ debug?: boolean;
+ }): Promise {
+ const searchParams = new URLSearchParams({
+ uuid: params.uuid,
+ supersetDomain: params.supersetDomain,
+ });
+ if (params.hideTitle) searchParams.set('hideTitle', 'true');
+ if (params.hideTab) searchParams.set('hideTab', 'true');
+ if (params.hideChartControls) searchParams.set('hideChartControls', 'true');
+ if (params.debug) searchParams.set('debug', 'true');
+
+ await this.page.goto(`${EMBEDDED.APP_URL}/?${searchParams.toString()}`);
+ }
+
+ /**
+ * FrameLocator for the embedded dashboard iframe.
+ */
+ get iframe(): FrameLocator {
+ return this.page.frameLocator(EmbeddedPage.SELECTORS.IFRAME);
+ }
+
+ /**
+ * Wait for the iframe to appear in the DOM.
+ */
+ async waitForIframe(options?: { timeout?: number }): Promise {
+ await this.page.locator(EmbeddedPage.SELECTORS.IFRAME).waitFor({
+ state: 'attached',
+ timeout: options?.timeout ?? EMBEDDED.IFRAME_LOAD,
+ });
+ }
+
+ /**
+ * Wait for dashboard content to render inside the iframe.
+ * Looks for the grid-container which indicates charts are loading/loaded.
+ */
+ async waitForDashboardContent(options?: { timeout?: number }): Promise {
+ const frame = this.iframe;
+ await frame
+ .locator('.grid-container, [data-test="grid-container"]')
+ .first()
+ .waitFor({
+ state: 'visible',
+ timeout: options?.timeout ?? EMBEDDED.DASHBOARD_RENDER,
+ });
+ }
+
+ /**
+ * Get the status text from the test app.
+ */
+ async getStatus(): Promise {
+ return (
+ (await this.page.locator(EmbeddedPage.SELECTORS.STATUS).textContent()) ??
+ ''
+ );
+ }
+
+ /**
+ * Get the error text, if any.
+ */
+ async getError(): Promise {
+ const errorEl = this.page.locator(EmbeddedPage.SELECTORS.ERROR);
+ const display = await errorEl.evaluate(el => getComputedStyle(el).display);
+ if (display === 'none') return '';
+ return (await errorEl.textContent()) ?? '';
+ }
+
+ /**
+ * Check if the dashboard title is visible inside the iframe.
+ */
+ async isTitleVisible(): Promise {
+ const frame = this.iframe;
+ return frame
+ .locator(
+ '[data-test="dashboard-header-container"] [data-test="editable-title-input"]',
+ )
+ .isVisible();
+ }
+}
diff --git a/superset-frontend/playwright/tests/embedded/embedded-dashboard.spec.ts b/superset-frontend/playwright/tests/embedded/embedded-dashboard.spec.ts
new file mode 100644
index 00000000000..75a47c87156
--- /dev/null
+++ b/superset-frontend/playwright/tests/embedded/embedded-dashboard.spec.ts
@@ -0,0 +1,303 @@
+/**
+ * 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.
+ */
+
+import { test, expect, Browser, BrowserContext, Page } from '@playwright/test';
+import { createServer, IncomingMessage, ServerResponse, Server } from 'http';
+import { readFileSync, existsSync } from 'fs';
+import { join, extname } from 'path';
+import { apiEnableEmbedding, getGuestToken } from '../../helpers/api/embedded';
+import { getDashboardBySlug } from '../../helpers/api/dashboard';
+import { EmbeddedPage } from '../../pages/EmbeddedPage';
+import { EMBEDDED } from '../../utils/constants';
+
+/**
+ * MIME types for the static file server
+ */
+const MIME_TYPES: Record = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.css': 'text/css',
+ '.json': 'application/json',
+};
+
+/**
+ * Superset domain (Flask server) — set by CI or defaults to local dev
+ */
+const SUPERSET_DOMAIN = (() => {
+ const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
+ return url.replace(/\/+$/, '');
+})();
+
+const SUPERSET_BASE_URL = SUPERSET_DOMAIN.endsWith('/')
+ ? SUPERSET_DOMAIN
+ : `${SUPERSET_DOMAIN}/`;
+
+/**
+ * Path to the SDK bundle built from superset-embedded-sdk/
+ */
+const SDK_BUNDLE_PATH = join(
+ __dirname,
+ '../../../../superset-embedded-sdk/bundle/index.js',
+);
+
+/**
+ * Path to the embedded test app static files
+ */
+const EMBED_APP_DIR = join(__dirname, '../../embedded-app');
+
+/**
+ * Create a minimal static file server for the embedded test app.
+ * Serves HTML from embedded-app/ and the SDK bundle from superset-embedded-sdk/bundle/.
+ */
+function createEmbedAppServer(): Server {
+ return createServer((req: IncomingMessage, res: ServerResponse) => {
+ const urlPath = req.url?.split('?')[0] || '/';
+
+ // Serve SDK bundle at /sdk/index.js
+ if (urlPath === '/sdk/index.js') {
+ if (!existsSync(SDK_BUNDLE_PATH)) {
+ res.writeHead(404);
+ res.end(
+ 'SDK bundle not found. Run: cd superset-embedded-sdk && npm ci && npm run build',
+ );
+ return;
+ }
+ res.writeHead(200, { 'Content-Type': 'text/javascript' });
+ res.end(readFileSync(SDK_BUNDLE_PATH));
+ return;
+ }
+
+ // Serve static files from embedded-app/
+ const filePath = join(
+ EMBED_APP_DIR,
+ urlPath === '/' ? 'index.html' : urlPath,
+ );
+ if (!existsSync(filePath)) {
+ res.writeHead(404);
+ res.end('Not found');
+ return;
+ }
+
+ const ext = extname(filePath);
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
+ res.writeHead(200, { 'Content-Type': contentType });
+ res.end(readFileSync(filePath));
+ });
+}
+
+/**
+ * Create a browser context authenticated as admin for API-only work
+ * (enabling embedding, restoring config). Caller is responsible for closing.
+ */
+function createAdminContext(browser: Browser): Promise {
+ return browser.newContext({
+ storageState: 'playwright/.auth/user.json',
+ baseURL: SUPERSET_BASE_URL,
+ });
+}
+
+// ─── Test Suite ────────────────────────────────────────────────────────────
+
+// Describe wrapper is needed for shared server state and serial execution:
+// all tests share a static file server on a fixed port and must not run in parallel.
+test.describe('Embedded Dashboard E2E', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let server: Server;
+ let embedUuid: string;
+ let dashboardId: number;
+
+ /**
+ * Set up a page to render the default embedded dashboard.
+ * Tests that need a different UUID or UI config should not use this helper.
+ */
+ async function setupEmbeddedPage(page: Page): Promise {
+ const embeddedPage = new EmbeddedPage(page);
+ await embeddedPage.exposeTokenFetcher(async () =>
+ getGuestToken(page, dashboardId),
+ );
+ await embeddedPage.goto({
+ uuid: embedUuid,
+ supersetDomain: SUPERSET_DOMAIN,
+ });
+ await embeddedPage.waitForIframe();
+ await embeddedPage.waitForDashboardContent();
+ return embeddedPage;
+ }
+
+ test.beforeAll(async ({ browser }) => {
+ // Skip all tests if the SDK bundle hasn't been built
+ test.skip(
+ !existsSync(SDK_BUNDLE_PATH),
+ 'Embedded SDK bundle not found. Build it with: cd superset-embedded-sdk && npm ci && npm run build',
+ );
+
+ // Start the embedded test app server
+ server = createEmbedAppServer();
+ await new Promise((resolve, reject) => {
+ server.on('error', reject);
+ server.listen(EMBEDDED.APP_PORT, () => resolve());
+ });
+
+ // Use a fresh context with auth to set up test data via API
+ const context = await createAdminContext(browser);
+ const setupPage = await context.newPage();
+
+ try {
+ // Find a well-known example dashboard
+ const dashboard = await getDashboardBySlug(setupPage, 'world_health');
+ if (!dashboard) {
+ throw new Error(
+ 'Dashboard "world_health" not found. Ensure load_examples ran in CI setup.',
+ );
+ }
+ dashboardId = dashboard.id;
+
+ // Enable embedding on the dashboard (empty allowed_domains = allow all)
+ const embedded = await apiEnableEmbedding(setupPage, dashboardId);
+ embedUuid = embedded.uuid;
+ } finally {
+ await context.close();
+ }
+ });
+
+ test.afterAll(async () => {
+ if (server) {
+ await new Promise(resolve => server.close(() => resolve()));
+ }
+ });
+
+ test('dashboard renders in embedded iframe', async ({ page }) => {
+ const embeddedPage = await setupEmbeddedPage(page);
+
+ // Verify the iframe src points to Superset's /embedded/ endpoint
+ const iframeSrc = await page
+ .locator('iframe[title="Embedded Dashboard"]')
+ .getAttribute('src');
+ expect(iframeSrc).toContain(`/embedded/${embedUuid}`);
+
+ // Verify no errors in the test app
+ const error = await embeddedPage.getError();
+ expect(error).toBe('');
+
+ // Baseline: title should be visible when hideTitle is not set
+ const titleVisible = await embeddedPage.isTitleVisible();
+ expect(titleVisible).toBe(true);
+ });
+
+ test('UI config hideTitle hides dashboard title', async ({ page }) => {
+ const embeddedPage = new EmbeddedPage(page);
+ await embeddedPage.exposeTokenFetcher(async () =>
+ getGuestToken(page, dashboardId),
+ );
+ await embeddedPage.goto({
+ uuid: embedUuid,
+ supersetDomain: SUPERSET_DOMAIN,
+ hideTitle: true,
+ });
+ await embeddedPage.waitForIframe();
+ await embeddedPage.waitForDashboardContent();
+
+ // The iframe URL should include uiConfig parameter
+ const iframeSrc = await page
+ .locator('iframe[title="Embedded Dashboard"]')
+ .getAttribute('src');
+ expect(iframeSrc).toContain('uiConfig=');
+
+ // Verify the title is actually hidden inside the iframe
+ const titleVisible = await embeddedPage.isTitleVisible();
+ expect(titleVisible).toBe(false);
+ });
+
+ test('charts render inside embedded iframe', async ({ page }) => {
+ const embeddedPage = await setupEmbeddedPage(page);
+
+ // Verify chart containers are present and visible in the iframe
+ const charts = embeddedPage.iframe.locator(
+ '.chart-container, [data-test="chart-container"]',
+ );
+ await expect(charts.first()).toBeVisible({
+ timeout: EMBEDDED.DASHBOARD_RENDER,
+ });
+ });
+
+ test('allowed_domains blocks unauthorized referrer', async ({
+ page,
+ browser,
+ }) => {
+ const context = await createAdminContext(browser);
+ const setupPage = await context.newPage();
+
+ try {
+ // Restrict to a domain that is NOT localhost:9000
+ const restrictedEmbed = await apiEnableEmbedding(setupPage, dashboardId, [
+ 'https://allowed.example.com',
+ ]);
+
+ const embeddedPage = new EmbeddedPage(page);
+ await embeddedPage.exposeTokenFetcher(async () =>
+ getGuestToken(page, dashboardId),
+ );
+ await embeddedPage.goto({
+ uuid: restrictedEmbed.uuid,
+ supersetDomain: SUPERSET_DOMAIN,
+ });
+
+ // The iframe should load but get a 403 from Superset's referrer check
+ await embeddedPage.waitForIframe();
+
+ // The dashboard content should NOT render (403 blocks the embedded page)
+ const content = embeddedPage.iframe.locator(
+ '.grid-container, [data-test="grid-container"]',
+ );
+ await expect(content).not.toBeVisible({ timeout: 5000 });
+ } finally {
+ // Restore the open embedding config for other tests
+ await apiEnableEmbedding(setupPage, dashboardId, []);
+ await context.close();
+ }
+ });
+
+ test('guest token enables dashboard data access', async ({ page }) => {
+ const embeddedPage = new EmbeddedPage(page);
+
+ let tokenCallCount = 0;
+ await embeddedPage.exposeTokenFetcher(async () => {
+ tokenCallCount += 1;
+ return getGuestToken(page, dashboardId);
+ });
+
+ await embeddedPage.goto({
+ uuid: embedUuid,
+ supersetDomain: SUPERSET_DOMAIN,
+ });
+ await embeddedPage.waitForIframe();
+ await embeddedPage.waitForDashboardContent();
+
+ // The SDK should have called fetchGuestToken at least once
+ expect(tokenCallCount).toBeGreaterThanOrEqual(1);
+
+ // Verify charts are actually rendering data (not just loading spinners)
+ const charts = embeddedPage.iframe.locator(
+ '.chart-container, [data-test="chart-container"]',
+ );
+ const chartCount = await charts.count();
+ expect(chartCount).toBeGreaterThan(0);
+ });
+});
diff --git a/superset-frontend/playwright/utils/constants.ts b/superset-frontend/playwright/utils/constants.ts
index ab142b01208..df0386a2030 100644
--- a/superset-frontend/playwright/utils/constants.ts
+++ b/superset-frontend/playwright/utils/constants.ts
@@ -75,3 +75,18 @@ export const TIMEOUT = {
*/
SLOW_TEST: 60000, // 60s for tests that chain multiple slow operations
} as const;
+
+/**
+ * Embedded dashboard test app configuration.
+ * The test app is served by a Node.js http server started in the test fixture.
+ */
+export const EMBEDDED = {
+ /** Port for the embedded test app static server */
+ APP_PORT: 9000,
+ /** Full URL for the embedded test app */
+ APP_URL: 'http://localhost:9000',
+ /** Timeout for iframe to appear in the DOM */
+ IFRAME_LOAD: 15000, // 15s
+ /** Timeout for dashboard content to render inside the iframe */
+ DASHBOARD_RENDER: 30000, // 30s
+} as const;