Compare commits

..

6 Commits

Author SHA1 Message Date
Joe Li
8b62bf0935 fix(embedded-e2e): strengthen assertion signal-to-noise from review
Address /review-code findings — the previous round's hardening fixed
flake but a few assertions still gave weak signals:

- The chart-rendered selector matched a still-loading chart cell, since
  Superset's `Loading` spinner itself renders an SVG. Exclude the spinner
  via `:not(:has([data-test="loading-indicator"]))` and centralize the
  selector as `EmbeddedPage.RENDERED_CHART_SELECTOR`.
- The "dashboard renders" test only proved iframe/header chrome, not the
  dashboard. Add `waitForChartRendered()` so the test name matches what
  it asserts.
- The `hideTitle` test passed for the wrong reason if the locator
  drifted (`toBeHidden()` succeeds for absent elements). Add an explicit
  `toHaveCount(0)` so the contrast against the baseline visibility check
  in test 1 is load-bearing.
- `tokenCallCount` was a `>=1` check that any rendered dashboard would
  satisfy. Tighten to `=== 1` to actually exercise the SDK's caching
  contract.
- Drop the redundant `appUrl` shadow of `appServer.url`.
- Move `import os` to module top in the docker-light config; document
  the strict `"true"`-only env-var truthiness convention.

Pre-commit clean (type-check, prettier, oxlint, ruff, mypy). Local
re-verification blocked by an unrelated worktree env issue (semantic
layers feature has incomplete state — the docker-compose-light stack
doesn't bind-mount superset-core/, so the image's stale copy lacks the
new submodule); CI on the chromium-embedded project will validate.
Changes are strictly stronger assertions and refactors so they cannot
turn a previously-passing test into a false positive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:11:17 -07:00
Joe Li
dc136e8898 feat(docker-light): enable embedded-dashboard support in local stack
Three additions to the lightweight local config so the embedded-dashboard
flow works against docker-compose-light without manually patching state:

- Read SUPERSET_FEATURE_<NAME> env vars into FEATURE_FLAGS so a docker
  .env-local can toggle features without editing tracked config.
- Disable Talisman so /embedded/<uuid> doesn't serve X-Frame-Options:
  SAMEORIGIN, which otherwise blocks cross-origin iframe embedding.
- Mirror Public to Gamma via PUBLIC_ROLE_LIKE so guest tokens can hit
  /api/v1/me/roles/ (CI does this implicitly via load_test_users; the
  light stack does not).

Required for the chromium-embedded Playwright project to run locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:11:17 -07:00
Joe Li
fdf8525c5d fix(embedded-e2e): harden Playwright suite against flake
Replaces racy one-shot checks with auto-retrying assertions, asserts the
referrer-block test against the deterministic 403 response (not iframe
content), uses an OS-allocated port for the static test app with
connection-tracked teardown, caches the JWT access token across tests,
sends CSRF on the guest-token call (page.request always carries the
storageState cookie, so JWT-only doesn't actually skip CSRF), and waits
for a real viz element inside chart containers rather than a class that
doesn't exist. Verified with --repeat-each=5 (25/25 passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:11:17 -07:00
Joe Li
f7b5de25e9 fix(embedded-e2e): use route allowlist in static test server
The test app server only ever serves /, /index.html, and /sdk/index.js,
so replace dynamic path joining with a fixed allowlist. This eliminates
the data flow from req.url to readFileSync that CodeQL flagged as a
path-traversal sink — the previous resolve+startsWith containment check
was correct but not recognized as a sanitizer by the analyzer.
2026-05-11 16:11:17 -07:00
Joe Li
918143fe90 ci(embedded-e2e): build SDK and configure test environment
- Add a build-embedded-sdk step to bashlib.sh and wire it into the
  superset-playwright and superset-e2e workflows so the SDK bundle is
  compiled before Playwright runs.
- Set SUPERSET_FEATURE_EMBEDDED_SUPERSET=true via workflow env so the
  feature flag only affects Playwright jobs. Setting it in the shared
  integration test config breaks unrelated Python tests because the
  security manager's guest-user paths access g.user through paths that
  most tests don't mock.
- Add CORS for localhost:9000 and TALISMAN_ENABLED=False to the
  integration test config. Talisman defaults to X-Frame-Options:
  SAMEORIGIN, which blocks the embedded dashboard from rendering
  inside an iframe hosted on a different port.
2026-05-11 16:11:17 -07:00
Joe Li
335a08a81b feat(embedded-e2e): add Playwright E2E tests for embedded dashboards
Adds five tests covering the embedded dashboard flow against the
world_health example: render, hideTitle UI config, chart rendering,
allowed_domains referrer check, and guest-token data access. Includes:

- A chromium-embedded Playwright project, excluded from the main
  project via testIgnore so it can be opted into separately.
- An EmbeddedPage page object and API helpers for embedding/guest
  tokens plus dashboard lookup by slug.
- A static test app (embedded-app/index.html) loaded from a minimal
  Node static server. Playwright bridges the guest-token fetch from
  Node into the browser via page.exposeFunction.
- EMBEDDED timeout/config constants.

Workflow integration and test-environment configuration land in a
follow-up commit.
2026-05-11 16:11:17 -07:00
18 changed files with 1002 additions and 264 deletions

View File

@@ -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"

View File

@@ -169,6 +169,7 @@ jobs:
PYTHONPATH: ${{ github.workspace }}
REDIS_PORT: 16379
GITHUB_TOKEN: ${{ github.token }}
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
services:
postgres:
image: postgres:17-alpine
@@ -239,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

View File

@@ -43,6 +43,7 @@ jobs:
PYTHONPATH: ${{ github.workspace }}
REDIS_PORT: 16379
GITHUB_TOKEN: ${{ github.token }}
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
services:
postgres:
image: postgres:17-alpine
@@ -113,6 +114,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

View File

@@ -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,31 @@ THUMBNAIL_CACHE_CONFIG = CACHE_CONFIG
# Disable Celery entirely for lightweight mode
CELERY_CONFIG = None # type: ignore[assignment,misc]
# Honor SUPERSET_FEATURE_<NAME> 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_")
},
}
# Disable Talisman so /embedded/<uuid> 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"

View File

@@ -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,22 @@ 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.
// 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',
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)

View File

@@ -17,13 +17,12 @@
* under the License.
*/
import { Locator, Page, expect } from '@playwright/test';
import { Locator, Page } from '@playwright/test';
import { Button, Checkbox, Table } from '../core';
const BULK_SELECT_SELECTORS = {
CONTROLS: '[data-test="bulk-select-controls"]',
ACTION: '[data-test="bulk-select-action"]',
HEADER_TOGGLE: '[data-test="header-toggle-all"]',
} as const;
/**
@@ -57,17 +56,10 @@ export class BulkSelect {
}
/**
* Enables bulk selection mode by clicking the toggle button.
*
* Waits for the bulk-select column header checkbox to render so the next
* row interaction does not race the table re-render that adds the
* checkbox column.
* Enables bulk selection mode by clicking the toggle button
*/
async enable(): Promise<void> {
await this.getToggleButton().click();
await this.page
.locator(BULK_SELECT_SELECTORS.HEADER_TOGGLE)
.waitFor({ state: 'visible' });
}
/**
@@ -80,27 +72,19 @@ export class BulkSelect {
}
/**
* Selects a row's checkbox in bulk select mode.
* Asserts the checkbox is checked afterwards so any state-update race
* surfaces here rather than as a missing bulk-action button later.
* Selects a row's checkbox in bulk select mode
* @param rowName - The name/text identifying the row to select
*/
async selectRow(rowName: string): Promise<void> {
const checkbox = this.getRowCheckbox(rowName);
await checkbox.check();
await expect(checkbox.element).toBeChecked();
await this.getRowCheckbox(rowName).check();
}
/**
* Deselects a row's checkbox in bulk select mode.
* Mirrors selectRow: asserts the unchecked state so any lingering selection
* surfaces here rather than as a stale bulk-action count later.
* Deselects a row's checkbox in bulk select mode
* @param rowName - The name/text identifying the row to deselect
*/
async deselectRow(rowName: string): Promise<void> {
const checkbox = this.getRowCheckbox(rowName);
await checkbox.uncheck();
await expect(checkbox.element).not.toBeChecked();
await this.getRowCheckbox(rowName).uncheck();
}
/**
@@ -123,11 +107,10 @@ export class BulkSelect {
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete").
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* @param actionName - The name of the bulk action to click
*/
async clickAction(actionName: string): Promise<void> {
const button = this.getActionButton(actionName);
await button.click();
await this.getActionButton(actionName).click();
}
}

View File

@@ -17,7 +17,6 @@
* under the License.
*/
import { expect } from '@playwright/test';
import { Modal, Input } from '../core';
/**
@@ -28,8 +27,7 @@ import { Modal, Input } from '../core';
*/
export class DeleteConfirmationModal extends Modal {
private static readonly SELECTORS = {
CONFIRMATION_INPUT: '[data-test="delete-modal-input"]',
CONFIRM_BUTTON: '[data-test="modal-confirm-button"]',
CONFIRMATION_INPUT: 'input[type="text"]',
};
/**
@@ -38,16 +36,12 @@ export class DeleteConfirmationModal extends Modal {
private get confirmationInput(): Input {
return new Input(
this.page,
this.element.locator(
DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT,
),
this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT),
);
}
/**
* Fills the confirmation input with the specified text.
* Waits for the input to be visible before filling so callers don't race
* with the modal's open animation / focus effect.
*
* @param confirmationText - The text to type
* @param options - Optional fill options (timeout, force)
@@ -63,25 +57,11 @@ export class DeleteConfirmationModal extends Modal {
confirmationText: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.confirmationInput.element.waitFor({
state: 'visible',
timeout: options?.timeout,
});
await this.confirmationInput.fill(confirmationText, options);
}
/**
* Clicks the Delete button in the footer.
*
* Targets the confirm button by data-test rather than going through
* Modal.clickFooterButton, which finds buttons by their visible text. The
* button label is i18n'd ("Delete" / "Supprimer" / …) so name-based lookups
* break in non-English locales.
*
* Also waits for the button to become enabled before clicking: it is
* disabled until the confirmation text matches "DELETE", and React's state
* update from fillConfirmationInput is asynchronous, so an immediate click
* can race the disabled→enabled transition.
* Clicks the Delete button in the footer
*
* @param options - Optional click options (timeout, force, delay)
*/
@@ -90,10 +70,6 @@ export class DeleteConfirmationModal extends Modal {
force?: boolean;
delay?: number;
}): Promise<void> {
const confirmButton = this.element.locator(
DeleteConfirmationModal.SELECTORS.CONFIRM_BUTTON,
);
await expect(confirmButton).toBeEnabled({ timeout: options?.timeout });
await confirmButton.click(options);
await this.clickFooterButton('Delete', options);
}
}

View File

@@ -0,0 +1,96 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embedded Dashboard Test App</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; }
#superset-container { width: 100%; height: 100vh; }
#superset-container iframe { width: 100%; height: 100%; border: none; }
#error { color: red; padding: 20px; display: none; }
#status { padding: 10px; font-family: monospace; font-size: 12px; }
</style>
</head>
<body>
<div id="status">Initializing embedded dashboard...</div>
<div id="error"></div>
<div id="superset-container" data-test="embedded-container"></div>
<script src="/sdk/index.js"></script>
<script>
(async function () {
const params = new URLSearchParams(window.location.search);
const uuid = params.get('uuid');
const supersetDomain = params.get('supersetDomain');
if (!uuid || !supersetDomain) {
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent =
'Missing required query params: uuid, supersetDomain';
return;
}
const statusEl = document.getElementById('status');
// fetchGuestToken is injected by Playwright via page.exposeFunction()
// Falls back to window.__guestToken for simple/static token injection
async function fetchGuestToken() {
if (typeof window.__fetchGuestToken === 'function') {
statusEl.textContent = 'Fetching guest token...';
const token = await window.__fetchGuestToken();
statusEl.textContent = 'Guest token received, loading dashboard...';
return token;
}
if (window.__guestToken) {
return window.__guestToken;
}
throw new Error('No guest token source available');
}
try {
// Parse optional UI config from query params
const uiConfig = {};
if (params.get('hideTitle') === 'true') uiConfig.hideTitle = true;
if (params.get('hideTab') === 'true') uiConfig.hideTab = true;
if (params.get('hideChartControls') === 'true') uiConfig.hideChartControls = true;
const dashboard = await supersetEmbeddedSdk.embedDashboard({
id: uuid,
supersetDomain: supersetDomain,
mountPoint: document.getElementById('superset-container'),
fetchGuestToken: fetchGuestToken,
dashboardUiConfig: Object.keys(uiConfig).length > 0 ? uiConfig : undefined,
debug: params.get('debug') === 'true',
});
statusEl.textContent = 'Dashboard embedded successfully';
// Expose dashboard API on window for Playwright assertions
window.__embeddedDashboard = dashboard;
} catch (err) {
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = 'Embed failed: ' + err.message;
statusEl.textContent = 'Error';
}
})();
</script>
</body>
</html>

View File

@@ -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<DashboardResult | null> {
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<DashboardResult | null> {
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<DashboardResult | null> {
return getDashboardByFilter(page, 'slug', slug);
}

View File

@@ -0,0 +1,136 @@
/**
* 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, getCsrfToken } 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<EmbeddedConfig> {
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<string> {
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<string> {
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 if the
// request also carries a session cookie (which page.request inherits from
// storageState), Flask-WTF still requires a matching X-CSRFToken. Send it
// unconditionally so this works whether the caller is authenticated via
// session, JWT, or both.
const { token: csrfToken } = await getCsrfToken(page);
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}`,
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
},
});
const guestBody = await guestResponse.json();
return guestBody.token;
}

View File

@@ -39,7 +39,7 @@ function getBaseUrl(): string {
return url.endsWith('/') ? url : `${url}/`;
}
interface CsrfResult {
export interface CsrfResult {
token: string;
error?: string;
}
@@ -49,7 +49,7 @@ 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<CsrfResult> {
export async function getCsrfToken(page: Page): Promise<CsrfResult> {
try {
const response = await page.request.get('api/v1/security/csrf_token/', {
failOnStatusCode: false,

View File

@@ -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<string>): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
return (
(await this.page.locator(EmbeddedPage.SELECTORS.STATUS).textContent()) ??
''
);
}
/**
* Get the error text, if any.
*/
async getError(): Promise<string> {
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()) ?? '';
}
}

View File

@@ -32,7 +32,6 @@ import {
expectStatusOneOf,
expectValidExportZip,
} from '../../helpers/api/assertions';
import { TIMEOUT } from '../../utils/constants';
/**
* Extend testWithAssets with chartListPage navigation (beforeEach equivalent).
@@ -63,11 +62,8 @@ test('should delete a chart with confirmation', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created chart appears.
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify chart is visible in list
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
// Click delete action button
await chartListPage.clickDeleteAction(chartName);
@@ -85,12 +81,12 @@ test('should delete a chart with confirmation', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears.
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify chart is removed from list (deleted rows leave the DOM)
await expect(chartListPage.getChartRow(chartName)).toHaveCount(0);
// Verify chart is removed from list
await expect(chartListPage.getChartRow(chartName)).not.toBeVisible();
// Backend verification: API returns 404
await expectDeleted(page, ENDPOINTS.CHART, chartId, {
@@ -115,11 +111,8 @@ test('should edit chart name via properties modal', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created chart appears.
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify chart is visible in list
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
// Click edit action to open properties modal
await chartListPage.clickEditAction(chartName);
@@ -144,7 +137,7 @@ test('should edit chart name via properties modal', async ({
// Modal should close
await propertiesModal.waitForHidden();
// Verify success toast appears.
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
@@ -171,11 +164,8 @@ test('should export a chart as a zip file', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created chart appears.
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify chart is visible in list
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
@@ -212,14 +202,9 @@ test('should bulk delete multiple charts', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created charts appear.
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify both charts are visible in list
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
// Enable bulk select mode
await chartListPage.clickBulkSelectButton();
@@ -244,13 +229,13 @@ test('should bulk delete multiple charts', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears.
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both charts are removed from list (deleted rows leave the DOM)
await expect(chartListPage.getChartRow(chart1.name)).toHaveCount(0);
await expect(chartListPage.getChartRow(chart2.name)).toHaveCount(0);
// Verify both charts are removed from list
await expect(chartListPage.getChartRow(chart1.name)).not.toBeVisible();
await expect(chartListPage.getChartRow(chart2.name)).not.toBeVisible();
// Backend verification: Both return 404
for (const chart of [chart1, chart2]) {
@@ -274,11 +259,8 @@ test('should edit chart name from card view', async ({ page, testAssets }) => {
await cardListPage.gotoCardView();
await cardListPage.waitForCardLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created chart card appears.
await expect(cardListPage.getChartCard(chartName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify chart card is visible
await expect(cardListPage.getChartCard(chartName)).toBeVisible();
// Open card dropdown and click edit
await cardListPage.clickCardEditAction(chartName);
@@ -303,16 +285,13 @@ test('should edit chart name from card view', async ({ page, testAssets }) => {
// Modal should close
await propertiesModal.waitForHidden();
// Verify success toast appears.
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify the renamed card appears in card view and old name is gone
// (the old card name is removed from the DOM after the rename re-render).
await expect(cardListPage.getChartCard(newName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(cardListPage.getChartCard(chartName)).toHaveCount(0);
await expect(cardListPage.getChartCard(newName)).toBeVisible();
await expect(cardListPage.getChartCard(chartName)).not.toBeVisible();
// Backend verification: API returns updated name
const response = await apiGetChart(page, chartId);
@@ -325,11 +304,6 @@ test('should bulk export multiple charts', async ({
chartListPage,
testAssets,
}) => {
// Chains create×2 → refresh → bulk select → export. Matches the
// sibling bulk-delete test's budget so the export response wait below
// can exceed the 30s default without hitting the test timeout.
test.setTimeout(TIMEOUT.SLOW_TEST);
// Create 2 throwaway charts for bulk export
const [chart1, chart2] = await Promise.all([
createTestChart(page, testAssets, test.info(), {
@@ -344,14 +318,9 @@ test('should bulk export multiple charts', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created charts appear.
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify both charts are visible in list
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
// Enable bulk select mode
await chartListPage.clickBulkSelectButton();
@@ -360,12 +329,8 @@ test('should bulk export multiple charts', async ({
await chartListPage.selectChartCheckbox(chart1.name);
await chartListPage.selectChartCheckbox(chart2.name);
// Set up API response intercept BEFORE the click that triggers it.
// Exports of multiple charts can take longer than 30s under load,
// so use SLOW_TEST instead of the default test-timeout-bound budget.
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT, {
timeout: TIMEOUT.SLOW_TEST,
});
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
// Click bulk export action
await chartListPage.clickBulkAction('Export');

View File

@@ -68,34 +68,33 @@ test('should delete a dashboard with confirmation', async ({
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dashboard appears.
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify dashboard is visible in list
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
// Click delete action button
await dashboardListPage.clickDeleteAction(dashboardName);
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForReady();
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button (waits for it to become enabled)
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears.
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify dashboard is removed from list
await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(0);
await expect(
dashboardListPage.getDashboardRow(dashboardName),
).not.toBeVisible();
// Backend verification: API returns 404
await expectDeleted(page, ENDPOINTS.DASHBOARD, dashboardId, {
@@ -120,11 +119,8 @@ test('should export a dashboard as a zip file', async ({
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dashboard appears.
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify dashboard is visible in list
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
@@ -161,14 +157,13 @@ test('should bulk delete multiple dashboards', async ({
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dashboards appear.
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify both dashboards are visible in list
await expect(
dashboardListPage.getDashboardRow(dashboard1.name),
).toBeVisible();
await expect(
dashboardListPage.getDashboardRow(dashboard2.name),
).toBeVisible();
// Enable bulk select mode
await dashboardListPage.clickBulkSelectButton();
@@ -193,17 +188,17 @@ test('should bulk delete multiple dashboards', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears.
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both dashboards are removed from list (deleted rows leave the DOM)
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toHaveCount(
0,
);
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toHaveCount(
0,
);
// Verify both dashboards are removed from list
await expect(
dashboardListPage.getDashboardRow(dashboard1.name),
).not.toBeVisible();
await expect(
dashboardListPage.getDashboardRow(dashboard2.name),
).not.toBeVisible();
// Backend verification: Both return 404
for (const dashboard of [dashboard1, dashboard2]) {
@@ -218,11 +213,6 @@ test('should bulk export multiple dashboards', async ({
dashboardListPage,
testAssets,
}) => {
// Chains create×2 → refresh → bulk select → export. Matches the
// sibling bulk-delete test's budget so the export response wait below
// can exceed the 30s default without hitting the test timeout.
test.setTimeout(TIMEOUT.SLOW_TEST);
// Create 2 throwaway dashboards for bulk export
const [dashboard1, dashboard2] = await Promise.all([
createTestDashboard(page, testAssets, test.info(), {
@@ -237,30 +227,25 @@ test('should bulk export multiple dashboards', async ({
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dashboards appear.
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify both dashboards are visible in list
await expect(
dashboardListPage.getDashboardRow(dashboard1.name),
).toBeVisible();
await expect(
dashboardListPage.getDashboardRow(dashboard2.name),
).toBeVisible();
// Enable bulk select mode (waits for the checkbox column to render)
// Enable bulk select mode
await dashboardListPage.clickBulkSelectButton();
// Select both dashboards (each call asserts the checkbox is checked)
// Select both dashboards
await dashboardListPage.selectDashboardCheckbox(dashboard1.name);
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
// Set up API response intercept BEFORE the click that triggers it.
// Exports of multiple dashboards can take longer than 30s under load,
// so use SLOW_TEST instead of the default test-timeout-bound budget.
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT, {
timeout: TIMEOUT.SLOW_TEST,
});
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
// Click bulk export action (waits for the action button to render)
// Click bulk export action
await dashboardListPage.clickBulkAction('Export');
// Wait for export API response and validate zip contains both dashboards
@@ -308,12 +293,12 @@ test.describe('import dashboard', () => {
label: `Dashboard ${dashboardId}`,
});
// Refresh to confirm dashboard is no longer in the list (deleted rows leave the DOM)
// Refresh to confirm dashboard is no longer in the list
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(
0,
);
await expect(
dashboardListPage.getDashboardRow(dashboardName),
).not.toBeVisible();
// Click the import button
await dashboardListPage.clickImportButton();
@@ -365,7 +350,7 @@ test.describe('import dashboard', () => {
// Modal should close on success
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
// Verify success toast appears.
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
@@ -373,11 +358,10 @@ test.describe('import dashboard', () => {
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-imported dashboard appears.
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify dashboard appears in list
await expect(
dashboardListPage.getDashboardRow(dashboardName),
).toBeVisible();
// Track for cleanup: look up the reimported dashboard by title
const reimported = await getDashboardByName(page, dashboardName);

View File

@@ -107,11 +107,8 @@ test('should delete a dataset with confirmation', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dataset appears.
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify dataset is visible in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// Click delete action button
await datasetListPage.clickDeleteAction(datasetName);
@@ -129,13 +126,14 @@ test('should delete a dataset with confirmation', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears with correct message.
// Verify success toast appears with correct message
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
const successToast = toast.getSuccess();
await expect(successToast).toBeVisible();
await expect(toast.getMessage()).toContainText('Deleted');
// Verify dataset is removed from list (deleted rows leave the DOM)
await expect(datasetListPage.getDatasetRow(datasetName)).toHaveCount(0);
// Verify dataset is removed from list
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
// Verify via API that dataset no longer exists (404)
await expectDeleted(page, ENDPOINTS.DATASET, datasetId, {
@@ -157,13 +155,10 @@ test('should duplicate a dataset with new name', async ({
);
const duplicateName = `duplicate_${Date.now()}_${test.info().parallelIndex}`;
// Navigate to list and verify original dataset is visible.
// The list query is asynchronous; allow extra time on slow CI.
// Navigate to list and verify original dataset is visible
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
// Set up response intercept to capture duplicate dataset ID
const duplicateResponsePromise = waitForPost(
@@ -206,14 +201,9 @@ test('should duplicate a dataset with new name', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// duplicate appears alongside the original.
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify both datasets exist in list
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
// API Verification: Fetch both datasets via detail API for consistent comparison
// (list API may return undefined for fields that detail API returns as null)
@@ -266,11 +256,6 @@ test('should export multiple datasets via bulk select action', async ({
datasetListPage,
testAssets,
}) => {
// Chains create×2 → refresh → bulk select → export. Matches the
// sibling bulk-delete test's budget so the export response wait below
// can exceed the 30s default without hitting the test timeout.
test.setTimeout(TIMEOUT.SLOW_TEST);
// Create 2 throwaway datasets for bulk export
const [dataset1, dataset2] = await Promise.all([
createTestDataset(page, testAssets, test.info(), {
@@ -285,14 +270,9 @@ test('should export multiple datasets via bulk select action', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created datasets appear.
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify both datasets are visible in list
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
// Enable bulk select mode
await datasetListPage.clickBulkSelectButton();
@@ -301,12 +281,8 @@ test('should export multiple datasets via bulk select action', async ({
await datasetListPage.selectDatasetCheckbox(dataset1.name);
await datasetListPage.selectDatasetCheckbox(dataset2.name);
// Set up API response intercept BEFORE the click that triggers it.
// Exports of multiple datasets can take longer than 30s under load,
// so use SLOW_TEST instead of the default test-timeout-bound budget.
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT, {
timeout: TIMEOUT.SLOW_TEST,
});
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT);
// Click bulk export action
await datasetListPage.clickBulkAction('Export');
@@ -336,11 +312,8 @@ test('should edit dataset name via modal', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dataset appears.
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify dataset is visible in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// Click edit action to open modal
await datasetListPage.clickEditAction(datasetName);
@@ -375,7 +348,7 @@ test('should edit dataset name via modal', async ({
// Modal should close
await editModal.waitForHidden();
// Verify success toast appears.
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
@@ -404,14 +377,9 @@ test('should bulk delete multiple datasets', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created datasets appear.
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify both datasets are visible in list
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
// Enable bulk select mode
await datasetListPage.clickBulkSelectButton();
@@ -436,13 +404,13 @@ test('should bulk delete multiple datasets', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears.
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both datasets are removed from list (deleted rows leave the DOM)
await expect(datasetListPage.getDatasetRow(dataset1.name)).toHaveCount(0);
await expect(datasetListPage.getDatasetRow(dataset2.name)).toHaveCount(0);
// Verify both datasets are removed from list
await expect(datasetListPage.getDatasetRow(dataset1.name)).not.toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).not.toBeVisible();
// Verify via API that datasets no longer exist (404)
await expectDeleted(page, ENDPOINTS.DATASET, dataset1.id, {
@@ -487,10 +455,10 @@ test.describe('import dataset', () => {
label: `Dataset ${datasetId}`,
});
// Refresh to confirm dataset is no longer in the list (deleted rows leave the DOM)
// Refresh to confirm dataset is no longer in the list
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await expect(datasetListPage.getDatasetRow(datasetName)).toHaveCount(0);
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
// Click the import button
await datasetListPage.clickImportButton();
@@ -539,7 +507,7 @@ test.describe('import dataset', () => {
// Modal should close on success
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
// Verify success toast appears.
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
@@ -547,11 +515,8 @@ test.describe('import dataset', () => {
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-imported dataset appears.
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Verify dataset appears in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// Track for cleanup: the dataset import API returns {"message": "OK"}
// with no ID, so look up the reimported dataset by name.

View File

@@ -0,0 +1,356 @@
/**
* 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<void>;
}
/**
* 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<EmbedAppServer> {
const sockets = new Set<Socket>();
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<void>((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<void>(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<BrowserContext> {
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<EmbeddedPage> {
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 {
// Best-effort cleanup — never fail teardown.
} 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/<uuid> 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.
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, { 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);
});
});

View File

@@ -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;

View File

@@ -78,6 +78,15 @@ FEATURE_FLAGS = {
WEBDRIVER_BASEURL = "http://0.0.0.0:8081/"
# Enable CORS for embedded dashboard E2E tests (test app on port 9000)
ENABLE_CORS = True
CORS_OPTIONS: dict = {
"origins": [
"http://localhost:9000",
],
"supports_credentials": True,
}
def GET_FEATURE_FLAGS_FUNC(ff): # noqa: N802
ff_copy = copy(ff)
@@ -86,6 +95,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"]]}