diff --git a/.github/workflows/bashlib.sh b/.github/workflows/bashlib.sh index 3b2d1af2f8e..569d3b0a22c 100644 --- a/.github/workflows/bashlib.sh +++ b/.github/workflows/bashlib.sh @@ -59,6 +59,15 @@ build-assets() { say "::endgroup::" } +build-embedded-sdk() { + cd "$GITHUB_WORKSPACE/superset-embedded-sdk" + + say "::group::Build embedded SDK bundle for E2E tests" + npm ci + npm run build + say "::endgroup::" +} + build-instrumented-assets() { cd "$GITHUB_WORKSPACE/superset-frontend" @@ -276,7 +285,12 @@ playwright-run() { cd "$GITHUB_WORKSPACE" local serverlog="${HOME}/superset-playwright.log" local port=8081 - PLAYWRIGHT_BASE_URL="http://localhost:${port}" + # Use 127.0.0.1 explicitly: `flask run` binds IPv4 only, and Node's DNS + # resolution for `localhost` can return `::1` first (IPv6), which then + # refuses against the IPv4 listener and surfaces as + # `connect ECONNREFUSED ::1:` in API helpers driven from Node + # (e.g., the embedded test app's exposed token fetcher). + PLAYWRIGHT_BASE_URL="http://127.0.0.1:${port}" if [ -n "$APP_ROOT" ]; then export SUPERSET_APP_ROOT=$APP_ROOT PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL}${APP_ROOT}/ diff --git a/.github/workflows/superset-e2e.yml b/.github/workflows/superset-e2e.yml index 7db24579c62..28944730c89 100644 --- a/.github/workflows/superset-e2e.yml +++ b/.github/workflows/superset-e2e.yml @@ -240,6 +240,11 @@ jobs: uses: ./.github/actions/cached-dependencies with: run: build-instrumented-assets + - name: Build embedded SDK + if: steps.check.outputs.python || steps.check.outputs.frontend + uses: ./.github/actions/cached-dependencies + with: + run: build-embedded-sdk - name: Install Playwright if: steps.check.outputs.python || steps.check.outputs.frontend uses: ./.github/actions/cached-dependencies diff --git a/.github/workflows/superset-playwright.yml b/.github/workflows/superset-playwright.yml index 915833fe3ce..c676078eb69 100644 --- a/.github/workflows/superset-playwright.yml +++ b/.github/workflows/superset-playwright.yml @@ -113,6 +113,11 @@ jobs: uses: ./.github/actions/cached-dependencies with: run: build-instrumented-assets + - name: Build embedded SDK + if: steps.check.outputs.python || steps.check.outputs.frontend + uses: ./.github/actions/cached-dependencies + with: + run: build-embedded-sdk - name: Install Playwright if: steps.check.outputs.python || steps.check.outputs.frontend uses: ./.github/actions/cached-dependencies @@ -125,6 +130,21 @@ jobs: NODE_OPTIONS: "--max-old-space-size=4096" with: run: playwright-run "${{ matrix.app_root }}" experimental/ + - name: Run Playwright (Embedded Tests) + if: steps.check.outputs.python || steps.check.outputs.frontend + uses: ./.github/actions/cached-dependencies + env: + NODE_OPTIONS: "--max-old-space-size=4096" + # Scope embedded-only env vars to this step. Setting them at the job + # level enabled the EMBEDDED_SUPERSET feature flag inside Flask for + # the preceding "Required Tests" and "Experimental Tests" steps too, + # which loads extra handlers and destabilizes the werkzeug dev + # server under the 2-worker Playwright load. Required Tests should + # match master's Flask configuration. + SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true" + INCLUDE_EMBEDDED: "true" + with: + run: playwright-run "${{ matrix.app_root }}" embedded - name: Set safe app root if: failure() id: set-safe-app-root diff --git a/docker/pythonpath_dev/superset_config_docker_light.py b/docker/pythonpath_dev/superset_config_docker_light.py index 1f053c2ce36..f5a10d4bd6a 100644 --- a/docker/pythonpath_dev/superset_config_docker_light.py +++ b/docker/pythonpath_dev/superset_config_docker_light.py @@ -18,6 +18,8 @@ # Configuration for docker-compose-light.yml - disables Redis and uses minimal services # Import all settings from the main config first +import os + from flask_caching.backends.filesystemcache import FileSystemCache from superset_config import * # noqa: F403 @@ -36,3 +38,32 @@ THUMBNAIL_CACHE_CONFIG = CACHE_CONFIG # Disable Celery entirely for lightweight mode CELERY_CONFIG = None # type: ignore[assignment,misc] + +# Honor SUPERSET_FEATURE_ env vars on top of any flags inherited from +# superset_config. Lets local dev/e2e enable features (e.g. EMBEDDED_SUPERSET) +# without editing shipped config files. Only the literal string "true" +# (case-insensitive) is treated as enabled — "1"/"yes"/"on" are not, matching +# the strict-string convention used elsewhere in Superset's env parsing. +FEATURE_FLAGS = { + **FEATURE_FLAGS, # noqa: F405 + **{ + name[len("SUPERSET_FEATURE_") :]: value.strip().lower() == "true" + for name, value in os.environ.items() + if name.startswith("SUPERSET_FEATURE_") + }, +} + +if os.environ.get("SUPERSET_FEATURE_EMBEDDED_SUPERSET", "").strip().lower() == "true": + # Disable Talisman so /embedded/ doesn't return X-Frame-Options:SAMEORIGIN. + # Without this, browsers refuse to render Superset inside an iframe from a + # different origin (i.e. the embedded SDK use case). Production/CI configures + # Talisman with explicit `frame-ancestors`; for the lightweight local stack we + # just turn it off. + TALISMAN_ENABLED = False + + # Guest tokens (used by the embedded SDK) inherit the "Public" role's perms. + # Out of the box Public has zero perms, so embedded dashboards immediately fail + # their first call (`/api/v1/me/roles/`) with 403. Mirror Public to Gamma — + # the standard read-only viewer role — so the embedded flow can authenticate + # and load dashboard data in local dev. + PUBLIC_ROLE_LIKE = "Gamma" diff --git a/superset-frontend/playwright.config.ts b/superset-frontend/playwright.config.ts index 9edef2176c8..dccb1d65141 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,29 @@ export default defineConfig({ // No storageState = clean browser with no cached cookies }, }, + // Strict 'true' check: non-empty strings like 'false' or '0' would + // otherwise enable the embedded project, matching the env-parsing + // convention used in docker/pythonpath_dev/superset_config_docker_light.py. + ...(process.env.INCLUDE_EMBEDDED?.toLowerCase() === 'true' + ? [ + { + // Embedded dashboard tests - validates the full embedding flow: + // external app -> SDK -> iframe -> guest token -> dashboard render. + // Each spec file mutates per-dashboard embedding state (UUID, + // allowed_domains) on a single shared Superset, so files must not + // run in parallel even if more are added later. + name: 'chromium-embedded', + testMatch: '**/tests/embedded/**/*.spec.ts', + fullyParallel: false, + use: { + browserName: 'chromium' as const, + 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/components/core/EditableTabs.ts b/superset-frontend/playwright/components/core/EditableTabs.ts index ca68181b498..5a1d0c50114 100644 --- a/superset-frontend/playwright/components/core/EditableTabs.ts +++ b/superset-frontend/playwright/components/core/EditableTabs.ts @@ -32,9 +32,25 @@ import { Tabs } from './Tabs'; export class EditableTabs extends Tabs { /** * Clicks the add-tab button rendered by antd in editable-card mode. + * + * When the tab strip overflows, antd renders two `Add tab` buttons: + * one hidden inside `.ant-tabs-nav-list` (visibility: hidden) and one + * visible inside `.ant-tabs-nav-operations`. Scope the click to the + * visible operations container so we never match the hidden inline copy. */ async addTab(): Promise { - await this.element.getByRole('button', { name: 'Add tab' }).click(); + const operationsButton = this.element + .locator('.ant-tabs-nav-operations') + .getByRole('button', { name: 'Add tab' }); + if ((await operationsButton.count()) > 0) { + await operationsButton.click(); + return; + } + // No overflow yet — the inline nav-list button is the only one rendered. + await this.element + .locator('.ant-tabs-nav-list') + .getByRole('button', { name: 'Add tab' }) + .click(); } /** diff --git a/superset-frontend/playwright/components/modals/EditDatasetModal.ts b/superset-frontend/playwright/components/modals/EditDatasetModal.ts index 06f29d600a8..77e33f1850a 100644 --- a/superset-frontend/playwright/components/modals/EditDatasetModal.ts +++ b/superset-frontend/playwright/components/modals/EditDatasetModal.ts @@ -32,6 +32,10 @@ export class EditDatasetModal extends Modal { UNLOCK_ICON: '[data-test="unlock"]', }; + // FAST_DEBOUNCE in @superset-ui/core is 250 ms; pad slightly so the + // debounced onChange has reliably flushed before we click Save. + private static readonly TEXT_CONTROL_DEBOUNCE_FLUSH_MS = 350; + private readonly tabs: Tabs; private readonly specificLocator: Locator; @@ -94,6 +98,7 @@ export class EditDatasetModal extends Modal { */ async fillName(name: string): Promise { await this.nameInput.fill(name); + await this.waitForTextControlDebounce(); } /** @@ -188,5 +193,17 @@ export class EditDatasetModal extends Modal { await dateFormatInput.element.waitFor({ state: 'visible' }); await dateFormatInput.clear(); await dateFormatInput.fill(format); + await this.waitForTextControlDebounce(); + } + + /** + * TextControl debounces its onChange by FAST_DEBOUNCE (250 ms) before + * propagating the value to the parent form. Wait past that window so a + * subsequent Save click captures the new value rather than the stale state. + */ + private async waitForTextControlDebounce(): Promise { + await this.page.waitForTimeout( + EditDatasetModal.TEXT_CONTROL_DEBOUNCE_FLUSH_MS, + ); } } diff --git a/superset-frontend/playwright/embedded-app/index.html b/superset-frontend/playwright/embedded-app/index.html new file mode 100644 index 00000000000..7e094f683b4 --- /dev/null +++ b/superset-frontend/playwright/embedded-app/index.html @@ -0,0 +1,95 @@ + + + + + + + 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..2efe61c6f8d --- /dev/null +++ b/superset-frontend/playwright/helpers/api/embedded.ts @@ -0,0 +1,133 @@ +/** + * 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: string; +} + +/** + * 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; +} + +/** + * Login as admin and return the JWT access token used by the guest_token + * endpoint. Cache the result at suite level (`beforeAll`) and pass it into + * `getGuestToken` to avoid a fresh login on every test. + * + * Defaults match `playwright/global-setup.ts` so credentials come from one + * source (env vars). Overrides via `options` win. + */ +export async function getAccessToken( + page: Page, + options?: { username?: string; password?: string }, +): Promise { + const username = + options?.username ?? process.env.PLAYWRIGHT_ADMIN_USERNAME ?? 'admin'; + const password = + options?.password ?? process.env.PLAYWRIGHT_ADMIN_PASSWORD ?? 'general'; + const loginResponse = await apiPost( + page, + ENDPOINTS.SECURITY_LOGIN, + { + username, + password, + provider: 'db', + refresh: true, + }, + { allowMissingCsrf: true }, + ); + const loginBody = await loginResponse.json(); + return loginBody.access_token; +} + +/** + * Get a guest token for an embedded dashboard. + * If `accessToken` is provided, the login round-trip is skipped — preferred + * for tests that fetch many tokens from a single suite. + * @returns Signed guest token string + */ +export async function getGuestToken( + page: Page, + dashboardId: number | string, + options?: { + accessToken?: string; + username?: string; + password?: string; + rls?: Array<{ dataset: number; clause: string }>; + }, +): Promise { + const accessToken = + options?.accessToken ?? + (await getAccessToken(page, { + username: options?.username, + password: options?.password, + })); + const rls = options?.rls ?? []; + + // The guest_token endpoint authenticates via JWT Bearer, but `page.request` + // inherits the session cookie from storageState, so Flask-WTF still requires + // a matching X-CSRFToken (plus a same-origin Referer). Route through + // `apiPost` so CSRF + Referer headers are built consistently with every + // other mutation helper; only the Authorization header is added here. + const guestResponse = await apiPost( + page, + ENDPOINTS.GUEST_TOKEN, + { + user: { + username: 'embedded_test_user', + first_name: 'Embedded', + last_name: 'TestUser', + }, + resources: [{ type: 'dashboard', id: String(dashboardId) }], + rls, + }, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + const guestBody = await guestResponse.json(); + return guestBody.token; +} diff --git a/superset-frontend/playwright/helpers/api/requests.ts b/superset-frontend/playwright/helpers/api/requests.ts index 9705d5e9b9c..76eabf3c40b 100644 --- a/superset-frontend/playwright/helpers/api/requests.ts +++ b/superset-frontend/playwright/helpers/api/requests.ts @@ -26,6 +26,40 @@ export interface ApiRequestOptions { allowMissingCsrf?: boolean; } +/** + * Werkzeug (Flask's dev server, used in CI) periodically drops connections + * mid-request under concurrent load — surfacing as `socket hang up`, + * `ECONNRESET`, or `ERR_EMPTY_RESPONSE`. These are transport-layer + * failures, not application errors, so retrying is safe. + * + * The matcher is intentionally narrow: only retry on signatures that + * indicate the server never produced a response. Application errors + * (4xx/5xx, HTTP-level CSRF rejection) bubble up unchanged. + */ +const TRANSIENT_NETWORK_ERROR = + /socket hang up|ECONNRESET|ERR_EMPTY_RESPONSE|ECONNREFUSED|EPIPE/i; +const TRANSIENT_RETRY_ATTEMPTS = 3; +const TRANSIENT_RETRY_BACKOFF_MS = 250; + +async function withTransientRetry(fn: () => Promise): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < TRANSIENT_RETRY_ATTEMPTS; attempt += 1) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (!TRANSIENT_NETWORK_ERROR.test(String(error))) { + throw error; + } + // Linear backoff — werkzeug recovers in 100–300 ms after a drop. + await new Promise(resolve => { + setTimeout(resolve, TRANSIENT_RETRY_BACKOFF_MS * (attempt + 1)); + }); + } + } + throw lastError; +} + /** * Get base URL for Referer header * Reads from environment variable configured in playwright.config.ts @@ -39,7 +73,7 @@ function getBaseUrl(): string { return url.endsWith('/') ? url : `${url}/`; } -interface CsrfResult { +export interface CsrfResult { token: string; error?: string; } @@ -49,11 +83,13 @@ interface CsrfResult { * Superset provides a CSRF token via api/v1/security/csrf_token/ * The session cookie is automatically included by page.request */ -async function getCsrfToken(page: Page): Promise { +export async function getCsrfToken(page: Page): Promise { try { - const response = await page.request.get('api/v1/security/csrf_token/', { - failOnStatusCode: false, - }); + const response = await withTransientRetry(() => + page.request.get('api/v1/security/csrf_token/', { + failOnStatusCode: false, + }), + ); if (!response.ok()) { return { @@ -107,11 +143,13 @@ export async function apiGet( url: string, options?: ApiRequestOptions, ): Promise { - return page.request.get(url, { - headers: options?.headers, - params: options?.params, - failOnStatusCode: options?.failOnStatusCode ?? true, - }); + return withTransientRetry(() => + page.request.get(url, { + headers: options?.headers, + params: options?.params, + failOnStatusCode: options?.failOnStatusCode ?? true, + }), + ); } /** @@ -126,12 +164,14 @@ export async function apiPost( ): Promise { const headers = await buildHeaders(page, options); - return page.request.post(url, { - data, - headers, - params: options?.params, - failOnStatusCode: options?.failOnStatusCode ?? true, - }); + return withTransientRetry(() => + page.request.post(url, { + data, + headers, + params: options?.params, + failOnStatusCode: options?.failOnStatusCode ?? true, + }), + ); } /** @@ -146,12 +186,14 @@ export async function apiPut( ): Promise { const headers = await buildHeaders(page, options); - return page.request.put(url, { - data, - headers, - params: options?.params, - failOnStatusCode: options?.failOnStatusCode ?? true, - }); + return withTransientRetry(() => + page.request.put(url, { + data, + headers, + params: options?.params, + failOnStatusCode: options?.failOnStatusCode ?? true, + }), + ); } /** @@ -166,12 +208,14 @@ export async function apiPatch( ): Promise { const headers = await buildHeaders(page, options); - return page.request.patch(url, { - data, - headers, - params: options?.params, - failOnStatusCode: options?.failOnStatusCode ?? true, - }); + return withTransientRetry(() => + page.request.patch(url, { + data, + headers, + params: options?.params, + failOnStatusCode: options?.failOnStatusCode ?? true, + }), + ); } /** @@ -185,9 +229,11 @@ export async function apiDelete( ): Promise { const headers = await buildHeaders(page, options); - return page.request.delete(url, { - headers, - params: options?.params, - failOnStatusCode: options?.failOnStatusCode ?? true, - }); + return withTransientRetry(() => + page.request.delete(url, { + headers, + params: options?.params, + failOnStatusCode: options?.failOnStatusCode ?? true, + }), + ); } diff --git a/superset-frontend/playwright/helpers/navigation.ts b/superset-frontend/playwright/helpers/navigation.ts new file mode 100644 index 00000000000..0a1baf9eb3f --- /dev/null +++ b/superset-frontend/playwright/helpers/navigation.ts @@ -0,0 +1,57 @@ +/** + * 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, Response } from '@playwright/test'; + +/** + * Werkzeug (Flask's dev server, used in CI) periodically drops connections + * during page navigation under concurrent load — surfacing as + * `ERR_EMPTY_RESPONSE`, `ERR_CONNECTION_RESET`, or a socket hang up. These + * are transport-layer failures, not application errors, so retrying the + * navigation is safe: the next request hits a fresh werkzeug worker thread. + * + * Application errors (4xx/5xx, JS exceptions during load) bubble up + * unchanged — the matcher is narrow on purpose. + */ +const TRANSIENT_NAV_ERROR = + /ERR_EMPTY_RESPONSE|ERR_CONNECTION_RESET|ERR_CONNECTION_CLOSED|socket hang up|ECONNRESET/i; +const NAV_RETRY_ATTEMPTS = 3; +const NAV_RETRY_BACKOFF_MS = 400; + +export async function gotoWithRetry( + page: Page, + url: string, + options?: Parameters[1], +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < NAV_RETRY_ATTEMPTS; attempt += 1) { + try { + return await page.goto(url, options); + } catch (error) { + lastError = error; + if (!TRANSIENT_NAV_ERROR.test(String(error))) { + throw error; + } + await new Promise(resolve => { + setTimeout(resolve, NAV_RETRY_BACKOFF_MS * (attempt + 1)); + }); + } + } + throw lastError; +} diff --git a/superset-frontend/playwright/pages/ChartCreationPage.ts b/superset-frontend/playwright/pages/ChartCreationPage.ts index 8dc38142938..ec7541b07d4 100644 --- a/superset-frontend/playwright/pages/ChartCreationPage.ts +++ b/superset-frontend/playwright/pages/ChartCreationPage.ts @@ -19,6 +19,7 @@ import { expect, Locator, Page } from '@playwright/test'; import { Button, Select } from '../components/core'; +import { gotoWithRetry } from '../helpers/navigation'; /** * Chart Creation Page object for the "Create a new chart" wizard. @@ -74,7 +75,7 @@ export class ChartCreationPage { * Navigate to the chart creation page */ async goto(): Promise { - await this.page.goto('chart/add'); + await gotoWithRetry(this.page, 'chart/add'); } /** diff --git a/superset-frontend/playwright/pages/ChartListPage.ts b/superset-frontend/playwright/pages/ChartListPage.ts index 49c3578cfb1..acdef115f24 100644 --- a/superset-frontend/playwright/pages/ChartListPage.ts +++ b/superset-frontend/playwright/pages/ChartListPage.ts @@ -20,6 +20,7 @@ import { Page, Locator } from '@playwright/test'; import { Table } from '../components/core'; import { BulkSelect } from '../components/ListView'; +import { gotoWithRetry } from '../helpers/navigation'; import { URL } from '../utils/urls'; /** @@ -52,14 +53,14 @@ export class ChartListPage { * (ListviewsDefaultCardView feature flag may enable card view). */ async goto(): Promise { - await this.page.goto(`${URL.CHART_LIST}?viewMode=table`); + await gotoWithRetry(this.page, `${URL.CHART_LIST}?viewMode=table`); } /** * Navigate to the chart list page in card view. */ async gotoCardView(): Promise { - await this.page.goto(`${URL.CHART_LIST}?viewMode=card`); + await gotoWithRetry(this.page, `${URL.CHART_LIST}?viewMode=card`); } /** diff --git a/superset-frontend/playwright/pages/CreateDatasetPage.ts b/superset-frontend/playwright/pages/CreateDatasetPage.ts index e7cf750a01d..62bd943849e 100644 --- a/superset-frontend/playwright/pages/CreateDatasetPage.ts +++ b/superset-frontend/playwright/pages/CreateDatasetPage.ts @@ -19,6 +19,7 @@ import { Page } from '@playwright/test'; import { Button, Select } from '../components/core'; +import { gotoWithRetry } from '../helpers/navigation'; /** * Create Dataset Page object for the dataset creation wizard. @@ -75,7 +76,7 @@ export class CreateDatasetPage { * Navigate to the create dataset page */ async goto(): Promise { - await this.page.goto('dataset/add/'); + await gotoWithRetry(this.page, 'dataset/add/'); } /** diff --git a/superset-frontend/playwright/pages/DashboardListPage.ts b/superset-frontend/playwright/pages/DashboardListPage.ts index 8c8472f0224..d432dd29fdf 100644 --- a/superset-frontend/playwright/pages/DashboardListPage.ts +++ b/superset-frontend/playwright/pages/DashboardListPage.ts @@ -20,6 +20,7 @@ import { Page, Locator } from '@playwright/test'; import { Button, Table } from '../components/core'; import { BulkSelect } from '../components/ListView'; +import { gotoWithRetry } from '../helpers/navigation'; import { URL } from '../utils/urls'; /** @@ -52,7 +53,7 @@ export class DashboardListPage { * (ListviewsDefaultCardView feature flag may enable card view). */ async goto(): Promise { - await this.page.goto(`${URL.DASHBOARD_LIST}?viewMode=table`); + await gotoWithRetry(this.page, `${URL.DASHBOARD_LIST}?viewMode=table`); } /** diff --git a/superset-frontend/playwright/pages/DashboardPage.ts b/superset-frontend/playwright/pages/DashboardPage.ts index f94695ad4fd..a61ff3415c1 100644 --- a/superset-frontend/playwright/pages/DashboardPage.ts +++ b/superset-frontend/playwright/pages/DashboardPage.ts @@ -19,6 +19,7 @@ import { Page, Download } from '@playwright/test'; import { Menu } from '../components/core'; +import { gotoWithRetry } from '../helpers/navigation'; import { TIMEOUT } from '../utils/constants'; /** @@ -43,7 +44,7 @@ export class DashboardPage { * @param slug - The dashboard slug (e.g., 'world_health') */ async gotoBySlug(slug: string): Promise { - await this.page.goto(`superset/dashboard/${slug}/`); + await gotoWithRetry(this.page, `superset/dashboard/${slug}/`); } /** @@ -51,7 +52,7 @@ export class DashboardPage { * @param id - The dashboard ID */ async gotoById(id: number): Promise { - await this.page.goto(`superset/dashboard/${id}/`); + await gotoWithRetry(this.page, `superset/dashboard/${id}/`); } /** diff --git a/superset-frontend/playwright/pages/DatasetListPage.ts b/superset-frontend/playwright/pages/DatasetListPage.ts index 77e9a87db25..a184ca3d126 100644 --- a/superset-frontend/playwright/pages/DatasetListPage.ts +++ b/superset-frontend/playwright/pages/DatasetListPage.ts @@ -20,6 +20,7 @@ import { Page, Locator } from '@playwright/test'; import { Button, Table } from '../components/core'; import { BulkSelect } from '../components/ListView'; +import { gotoWithRetry } from '../helpers/navigation'; import { URL } from '../utils/urls'; /** @@ -54,7 +55,7 @@ export class DatasetListPage { * Navigate to the dataset list page */ async goto(): Promise { - await this.page.goto(URL.DATASET_LIST); + await gotoWithRetry(this.page, URL.DATASET_LIST); } /** diff --git a/superset-frontend/playwright/pages/EmbeddedPage.ts b/superset-frontend/playwright/pages/EmbeddedPage.ts new file mode 100644 index 00000000000..b9b61612937 --- /dev/null +++ b/superset-frontend/playwright/pages/EmbeddedPage.ts @@ -0,0 +1,172 @@ +/** + * 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, Locator, expect } from '@playwright/test'; +import { EMBEDDED } from '../utils/constants'; + +/** + * Page object for the embedded dashboard test app. + * + * The test app runs on a separate origin (its origin is assigned per-suite + * via an OS-allocated port) 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. + * `appUrl` is the origin of the static test app (assigned dynamically by + * the spec's beforeAll fixture so workers don't collide on a fixed port). + */ + async goto(params: { + appUrl: string; + 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(`${params.appUrl}/?${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 AND have its src set. + * The SDK appends the iframe element before assigning src, so a bare + * `state: 'attached'` wait races the src read. + */ + async waitForIframe(options?: { timeout?: number }): Promise { + const locator = this.page.locator(EmbeddedPage.SELECTORS.IFRAME); + await locator.waitFor({ + state: 'attached', + timeout: options?.timeout ?? EMBEDDED.IFRAME_LOAD, + }); + await expect(locator).toHaveAttribute('src', /.+/, { + 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, + }); + } + + /** + * Matches a chart cell that has finished loading: it contains a real viz + * element (svg, canvas, table) AND no longer hosts the `Loading` spinner + * (`data-test="loading-indicator"`). Excluding the spinner matters — + * the spinner itself renders an SVG, so a `:has(svg)`-only check can match + * a still-loading chart for the wrong reason. + */ + static readonly RENDERED_CHART_SELECTOR = + '[data-test="chart-container"]:has(svg, canvas, table):not(:has([data-test="loading-indicator"]))'; + + /** + * Wait for at least one chart to finish rendering — viz drawn AND no + * loading spinner in that cell. + */ + async waitForChartRendered(options?: { timeout?: number }): Promise { + await this.iframe + .locator(EmbeddedPage.RENDERED_CHART_SELECTOR) + .first() + .waitFor({ + state: 'visible', + timeout: options?.timeout ?? EMBEDDED.CHART_RENDER, + }); + } + + /** + * Locator for the dashboard title input inside the iframe. + * Returned as a `Locator` so callers can use `expect(...).toBeVisible()` / + * `.toBeHidden()` with auto-retry instead of one-shot `.isVisible()`. + */ + get titleLocator(): Locator { + return this.iframe.locator( + '[data-test="dashboard-header-container"] [data-test="editable-title-input"]', + ); + } + + /** + * 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()) ?? ''; + } +} diff --git a/superset-frontend/playwright/pages/SqlLabPage.ts b/superset-frontend/playwright/pages/SqlLabPage.ts index 9c582fc73a7..48b2453014c 100644 --- a/superset-frontend/playwright/pages/SqlLabPage.ts +++ b/superset-frontend/playwright/pages/SqlLabPage.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Page, Locator, Response } from '@playwright/test'; +import { Page, Locator, Response, expect } from '@playwright/test'; import { AceEditor } from '../components/core/AceEditor'; import { AgGrid } from '../components/core/AgGrid'; import { Button } from '../components/core/Button'; @@ -25,6 +25,7 @@ import { EditableTabs } from '../components/core/EditableTabs'; import { Popover } from '../components/core/Popover'; import { Select } from '../components/core/Select'; import { waitForPost } from '../helpers/api/intercepts'; +import { gotoWithRetry } from '../helpers/navigation'; import { URL } from '../utils/urls'; import { TIMEOUT } from '../utils/constants'; @@ -62,12 +63,17 @@ export class SqlLabPage { // ── Navigation ── async goto(): Promise { - await this.page.goto(URL.SQLLAB, { waitUntil: 'domcontentloaded' }); + await gotoWithRetry(this.page, URL.SQLLAB, { + waitUntil: 'domcontentloaded', + }); } async waitForPageLoad(options?: { timeout?: number }): Promise { - // SQL Lab with dev server can be slow on first load (webpack HMR + React hydration) - const timeout = options?.timeout ?? TIMEOUT.QUERY_EXECUTION; + // SQL Lab is the heaviest bundle in Superset — the editor tabs container + // doesn't render until the lazy chunk and async tab state (tabstateview) + // both resolve. On cold-cache CI workers under werkzeug load this can + // exceed 15 s, so use SLOW_TEST (60 s) rather than QUERY_EXECUTION here. + const timeout = options?.timeout ?? TIMEOUT.SLOW_TEST; await this.editorTabs.element.waitFor({ state: 'visible', timeout }); } @@ -310,15 +316,28 @@ export class SqlLabPage { */ async executeQuery(sql: string): Promise { await this.setQuery(sql); + // Run Query is disabled until BOTH sql is set (just done) AND a + // database is selected. On fresh CI users the default database may + // not be populated when ensureEditorReady() returns, so block here + // until the button is actually clickable before kicking off the + // response/loading watchers — otherwise their 15 s timers run out + // before the click can even fire. Use SLOW_TEST: under werkzeug + // load default-db bootstrap can take >15 s. + await expect(this.runQueryButton.element).toBeEnabled({ + timeout: TIMEOUT.SLOW_TEST, + }); + // Use SLOW_TEST for /sqllab/execute/ — under werkzeug stress the + // round-trip can exceed 15 s even for trivial queries because the + // dev server time-shares a single Python thread across all workers. const responsePromise = waitForPost(this.page, 'api/v1/sqllab/execute/', { - timeout: TIMEOUT.QUERY_EXECUTION, + timeout: TIMEOUT.SLOW_TEST, }); // Start observing the loading indicator BEFORE clicking Run so we // catch it even for fast queries. QueryStatusBar (.ant-steps) appears // when SQL Lab enters the running state and unmounts the results grid. const loadingStarted = this.resultsPane .locator('.ant-steps') - .waitFor({ state: 'visible', timeout: TIMEOUT.QUERY_EXECUTION }); + .waitFor({ state: 'visible', timeout: TIMEOUT.SLOW_TEST }); await this.runQueryButton.click(); const [, response] = await Promise.all([loadingStarted, responsePromise]); return response; @@ -335,7 +354,11 @@ export class SqlLabPage { expectHeader: string, options?: { timeout?: number }, ): Promise { - const timeout = options?.timeout ?? TIMEOUT.QUERY_EXECUTION; + // AG Grid is heavy and lazy-rendered. Under werkzeug stress the FE + // sometimes takes >15 s to hydrate results after the query returns. + // Default to SLOW_TEST so a slow grid mount doesn't masquerade as a + // query failure (the response status was already asserted upstream). + const timeout = options?.timeout ?? TIMEOUT.SLOW_TEST; // Wait for QueryStatusBar to disappear — proves the loading → ready // transition completed. If already hidden (fast query finished before // this call), resolves immediately since executeQuery() already observed 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..9a717c50108 --- /dev/null +++ b/superset-frontend/playwright/tests/embedded/embedded-dashboard.spec.ts @@ -0,0 +1,362 @@ +/** + * 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 { AddressInfo, Socket } from 'net'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { + apiEnableEmbedding, + getAccessToken, + getGuestToken, +} from '../../helpers/api/embedded'; +import { getDashboardBySlug } from '../../helpers/api/dashboard'; +import { EmbeddedPage } from '../../pages/EmbeddedPage'; + +/** + * 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 only a fixed allowlist of routes — the test app references just + * its index.html and the SDK bundle, so anything else is 404. + */ +const INDEX_HTML_PATH = join(EMBED_APP_DIR, 'index.html'); + +interface EmbedAppServer { + server: Server; + url: string; + close: () => Promise; +} + +/** + * Start the static test app on an OS-assigned ephemeral port. Tracks open + * sockets so close() doesn't hang on iframe keep-alive connections, and so + * different workers/retries never collide on a fixed port. + */ +async function startEmbedAppServer(): Promise { + const sockets = new Set(); + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const urlPath = req.url?.split('?')[0] || '/'; + + 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; + } + + if (urlPath === '/' || urlPath === '/index.html') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(readFileSync(INDEX_HTML_PATH)); + return; + } + + res.writeHead(404); + res.end('Not found'); + }); + + server.on('connection', socket => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + + const address = server.address() as AddressInfo; + const url = `http://127.0.0.1:${address.port}`; + + return { + server, + url, + close: () => + new Promise(resolve => { + for (const socket of sockets) socket.destroy(); + sockets.clear(); + server.close(() => resolve()); + }), + }; +} + +/** + * 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 and must not run in parallel. +test.describe('Embedded Dashboard E2E', () => { + test.describe.configure({ mode: 'serial' }); + + // The full embedded chain (login → guest token → iframe → dashboard render + // → chart render) routinely exceeds the 30s default on cold CI starts. + test.setTimeout(60000); + + let appServer: EmbedAppServer; + let accessToken: string; + 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, { accessToken }), + ); + await embeddedPage.goto({ + appUrl: appServer.url, + 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', + ); + + appServer = await startEmbedAppServer(); + + // Use a fresh context with auth to set up test data via API + const context = await createAdminContext(browser); + const setupPage = await context.newPage(); + + try { + 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; + + // Cache the JWT access token so tests don't re-login per guest token. + accessToken = await getAccessToken(setupPage); + } finally { + await context.close(); + } + }); + + test.afterAll(async ({ browser }) => { + // Defensive restore in case the allowed_domains test failed mid-flight. + if (dashboardId !== undefined) { + const context = await createAdminContext(browser); + try { + const setupPage = await context.newPage(); + await apiEnableEmbedding(setupPage, dashboardId, []); + } catch (err) { + // eslint-disable-next-line no-console + console.error('[embedded teardown] restore failed:', err); + } finally { + await context.close(); + } + } + + if (appServer) await appServer.close(); + }); + + test('dashboard renders in embedded iframe', async ({ page }) => { + const embeddedPage = await setupEmbeddedPage(page); + + // Verify the iframe src points to Superset's /embedded/ endpoint + await expect( + page.locator('iframe[title="Embedded Dashboard"]'), + ).toHaveAttribute('src', new RegExp(`/embedded/${embedUuid}`)); + + // Verify no errors in the test app + expect(await embeddedPage.getError()).toBe(''); + + // Baseline: title should be visible when hideTitle is not set. This + // doubles as a positive existence check the `hideTitle` test relies on + // for distinguishing "title was hidden" from "selector is wrong". + await expect(embeddedPage.titleLocator).toBeVisible(); + + // Prove the dashboard actually renders, not just the chrome. + await embeddedPage.waitForChartRendered(); + }); + + test('UI config hideTitle hides dashboard title', async ({ page }) => { + const embeddedPage = new EmbeddedPage(page); + await embeddedPage.exposeTokenFetcher(async () => + getGuestToken(page, dashboardId, { accessToken }), + ); + await embeddedPage.goto({ + appUrl: appServer.url, + uuid: embedUuid, + supersetDomain: SUPERSET_DOMAIN, + hideTitle: true, + }); + await embeddedPage.waitForIframe(); + await embeddedPage.waitForDashboardContent(); + + // The iframe URL should include uiConfig parameter + await expect( + page.locator('iframe[title="Embedded Dashboard"]'), + ).toHaveAttribute('src', /uiConfig=/); + + // hideTitle removes the header from the DOM (rather than CSS-hiding it), + // so toBeHidden + toHaveCount(0) together assert: not visible AND + // confirmed-removed (so the test can't pass for the wrong reason if the + // selector ever drifts — the baseline test asserts the selector matches + // when hideTitle is off). + await expect(embeddedPage.titleLocator).toBeHidden(); + await expect(embeddedPage.titleLocator).toHaveCount(0); + }); + + test('charts render inside embedded iframe', async ({ page }) => { + const embeddedPage = await setupEmbeddedPage(page); + + await embeddedPage.waitForChartRendered(); + const renderedCharts = embeddedPage.iframe.locator( + EmbeddedPage.RENDERED_CHART_SELECTOR, + ); + expect(await renderedCharts.count()).toBeGreaterThan(0); + }); + + 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 the test app's origin + const restrictedEmbed = await apiEnableEmbedding(setupPage, dashboardId, [ + 'https://allowed.example.com', + ]); + + const embeddedPage = new EmbeddedPage(page); + await embeddedPage.exposeTokenFetcher(async () => + getGuestToken(page, dashboardId, { accessToken }), + ); + + // The deterministic signal that the referrer check fired is the HTTP + // status of the /embedded/ response — assert that directly rather + // than racing against cross-origin iframe rendering. + const embeddedResponsePromise = page.waitForResponse( + resp => + resp.url().includes(`/embedded/${restrictedEmbed.uuid}`) && + resp.request().resourceType() === 'document', + ); + + await embeddedPage.goto({ + appUrl: appServer.url, + uuid: restrictedEmbed.uuid, + supersetDomain: SUPERSET_DOMAIN, + }); + + const response = await embeddedResponsePromise; + expect(response.status()).toBe(403); + } finally { + // Restore the open embedding config for other tests in this file. + try { + await apiEnableEmbedding(setupPage, dashboardId, []); + } catch (err) { + // eslint-disable-next-line no-console + console.error('[embedded teardown] restore failed:', err); + } + 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, { accessToken }); + }); + + await embeddedPage.goto({ + appUrl: appServer.url, + uuid: embedUuid, + supersetDomain: SUPERSET_DOMAIN, + }); + await embeddedPage.waitForIframe(); + await embeddedPage.waitForDashboardContent(); + await embeddedPage.waitForChartRendered(); + + // The SDK fetches the token exactly once per embed (caching is the + // SDK's responsibility, not ours) — assert the stronger invariant. + expect(tokenCallCount).toBe(1); + + // Confirm at least one chart actually rendered with data, not just its shell + const renderedCharts = embeddedPage.iframe.locator( + EmbeddedPage.RENDERED_CHART_SELECTOR, + ); + expect(await renderedCharts.count()).toBeGreaterThan(0); + }); +}); diff --git a/superset-frontend/playwright/utils/constants.ts b/superset-frontend/playwright/utils/constants.ts index ab142b01208..9ab2b37e4ec 100644 --- a/superset-frontend/playwright/utils/constants.ts +++ b/superset-frontend/playwright/utils/constants.ts @@ -75,3 +75,16 @@ 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 = { + /** 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 + /** Timeout for individual chart cells to finish rendering */ + CHART_RENDER: 30000, // 30s +} as const; diff --git a/tests/integration_tests/superset_test_config.py b/tests/integration_tests/superset_test_config.py index c16591ed375..56ab8ddd194 100644 --- a/tests/integration_tests/superset_test_config.py +++ b/tests/integration_tests/superset_test_config.py @@ -86,6 +86,7 @@ def GET_FEATURE_FLAGS_FUNC(ff): # noqa: N802 TESTING = True +TALISMAN_ENABLED = False WTF_CSRF_ENABLED = False FAB_ROLES = {"TestRole": [["Security", "menu_access"], ["List Users", "menu_access"]]}