mirror of
https://github.com/apache/superset.git
synced 2026-05-13 11:55:16 +00:00
Compare commits
6 Commits
fix-flakey
...
embedded-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b62bf0935 | ||
|
|
dc136e8898 | ||
|
|
fdf8525c5d | ||
|
|
f7b5de25e9 | ||
|
|
918143fe90 | ||
|
|
335a08a81b |
9
.github/workflows/bashlib.sh
vendored
9
.github/workflows/bashlib.sh
vendored
@@ -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"
|
||||
|
||||
|
||||
6
.github/workflows/superset-e2e.yml
vendored
6
.github/workflows/superset-e2e.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/superset-playwright.yml
vendored
6
.github/workflows/superset-playwright.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
96
superset-frontend/playwright/embedded-app/index.html
Normal file
96
superset-frontend/playwright/embedded-app/index.html
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
136
superset-frontend/playwright/helpers/api/embedded.ts
Normal file
136
superset-frontend/playwright/helpers/api/embedded.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
172
superset-frontend/playwright/pages/EmbeddedPage.ts
Normal file
172
superset-frontend/playwright/pages/EmbeddedPage.ts
Normal 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()) ?? '';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]]}
|
||||
|
||||
Reference in New Issue
Block a user