mirror of
https://github.com/apache/superset.git
synced 2026-04-30 13:34:20 +00:00
Compare commits
7 Commits
codex/fix-
...
embedded-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d30e9eb17d | ||
|
|
a4635f84bc | ||
|
|
01c84a3bf1 | ||
|
|
701eea222a | ||
|
|
3536adf340 | ||
|
|
98e89e6e5d | ||
|
|
2e3ae7a061 |
9
.github/workflows/bashlib.sh
vendored
9
.github/workflows/bashlib.sh
vendored
@@ -59,6 +59,15 @@ build-assets() {
|
|||||||
say "::endgroup::"
|
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() {
|
build-instrumented-assets() {
|
||||||
cd "$GITHUB_WORKSPACE/superset-frontend"
|
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 }}
|
PYTHONPATH: ${{ github.workspace }}
|
||||||
REDIS_PORT: 16379
|
REDIS_PORT: 16379
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
@@ -239,6 +240,11 @@ jobs:
|
|||||||
uses: ./.github/actions/cached-dependencies
|
uses: ./.github/actions/cached-dependencies
|
||||||
with:
|
with:
|
||||||
run: build-instrumented-assets
|
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
|
- name: Install Playwright
|
||||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||||
uses: ./.github/actions/cached-dependencies
|
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 }}
|
PYTHONPATH: ${{ github.workspace }}
|
||||||
REDIS_PORT: 16379
|
REDIS_PORT: 16379
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
@@ -113,6 +114,11 @@ jobs:
|
|||||||
uses: ./.github/actions/cached-dependencies
|
uses: ./.github/actions/cached-dependencies
|
||||||
with:
|
with:
|
||||||
run: build-instrumented-assets
|
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
|
- name: Install Playwright
|
||||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||||
uses: ./.github/actions/cached-dependencies
|
uses: ./.github/actions/cached-dependencies
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export default defineConfig({
|
|||||||
testIgnore: [
|
testIgnore: [
|
||||||
'**/tests/auth/**/*.spec.ts',
|
'**/tests/auth/**/*.spec.ts',
|
||||||
'**/tests/sqllab/**/*.spec.ts',
|
'**/tests/sqllab/**/*.spec.ts',
|
||||||
|
'**/tests/embedded/**/*.spec.ts',
|
||||||
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
|
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
|
||||||
],
|
],
|
||||||
use: {
|
use: {
|
||||||
@@ -132,6 +133,18 @@ export default defineConfig({
|
|||||||
// No storageState = clean browser with no cached cookies
|
// No storageState = clean browser with no cached cookies
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Embedded dashboard tests - validates the full embedding flow:
|
||||||
|
// external app -> SDK -> iframe -> guest token -> dashboard render
|
||||||
|
name: 'chromium-embedded',
|
||||||
|
testMatch: '**/tests/embedded/**/*.spec.ts',
|
||||||
|
use: {
|
||||||
|
browserName: 'chromium',
|
||||||
|
testIdAttribute: 'data-test',
|
||||||
|
// Uses admin auth for API calls to configure embedding and get guest tokens
|
||||||
|
storageState: 'playwright/.auth/user.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// Web server setup - disabled in CI (Flask started separately in workflow)
|
// Web server setup - disabled in CI (Flask started separately in workflow)
|
||||||
|
|||||||
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;
|
published?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function getDashboardByFilter(
|
||||||
* 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,
|
page: Page,
|
||||||
title: string,
|
col: 'dashboard_title' | 'slug',
|
||||||
|
value: string,
|
||||||
): Promise<DashboardResult | null> {
|
): Promise<DashboardResult | null> {
|
||||||
const filter = {
|
const queryParam = rison.encode({
|
||||||
filters: [
|
filters: [{ col, opr: 'eq', value }],
|
||||||
{
|
});
|
||||||
col: 'dashboard_title',
|
|
||||||
opr: 'eq',
|
|
||||||
value: title,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const queryParam = rison.encode(filter);
|
|
||||||
const response = await apiGet(
|
const response = await apiGet(
|
||||||
page,
|
page,
|
||||||
`${ENDPOINTS.DASHBOARD}?q=${queryParam}`,
|
`${ENDPOINTS.DASHBOARD}?q=${queryParam}`,
|
||||||
@@ -169,3 +157,29 @@ export async function getDashboardByName(
|
|||||||
|
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
113
superset-frontend/playwright/helpers/api/embedded.ts
Normal file
113
superset-frontend/playwright/helpers/api/embedded.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page } from '@playwright/test';
|
||||||
|
import { apiPost, apiPut } from './requests';
|
||||||
|
import { ENDPOINTS as DASHBOARD_ENDPOINTS } from './dashboard';
|
||||||
|
|
||||||
|
export const ENDPOINTS = {
|
||||||
|
SECURITY_LOGIN: 'api/v1/security/login',
|
||||||
|
GUEST_TOKEN: 'api/v1/security/guest_token/',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface EmbeddedConfig {
|
||||||
|
uuid: string;
|
||||||
|
allowed_domains: string[];
|
||||||
|
dashboard_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable embedding on a dashboard and return the embedded UUID.
|
||||||
|
* Uses PUT (upsert) to preserve UUID across repeated calls.
|
||||||
|
* @param page - Playwright page instance (provides authentication context)
|
||||||
|
* @param dashboardIdOrSlug - Numeric dashboard id or slug
|
||||||
|
* @param allowedDomains - Domains allowed to embed; empty array allows all
|
||||||
|
* @returns Embedded config with UUID, allowed_domains, and dashboard_id
|
||||||
|
*/
|
||||||
|
export async function apiEnableEmbedding(
|
||||||
|
page: Page,
|
||||||
|
dashboardIdOrSlug: number | string,
|
||||||
|
allowedDomains: string[] = [],
|
||||||
|
): Promise<EmbeddedConfig> {
|
||||||
|
const response = await apiPut(
|
||||||
|
page,
|
||||||
|
`${DASHBOARD_ENDPOINTS.DASHBOARD}${dashboardIdOrSlug}/embedded`,
|
||||||
|
{ allowed_domains: allowedDomains },
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.result as EmbeddedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a guest token for an embedded dashboard.
|
||||||
|
* Uses the admin login flow (login → access_token → guest_token).
|
||||||
|
* @param page - Playwright page instance (used for request context)
|
||||||
|
* @param dashboardId - Dashboard id to grant access to
|
||||||
|
* @param options - Optional login credentials and RLS rules
|
||||||
|
* @returns Signed guest token string
|
||||||
|
*/
|
||||||
|
export async function getGuestToken(
|
||||||
|
page: Page,
|
||||||
|
dashboardId: number | string,
|
||||||
|
options?: {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
rls?: Array<{ dataset: number; clause: string }>;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
const username = options?.username ?? 'admin';
|
||||||
|
const password = options?.password ?? 'general';
|
||||||
|
const rls = options?.rls ?? [];
|
||||||
|
|
||||||
|
// Step 1: Login to get access token
|
||||||
|
const loginResponse = await apiPost(
|
||||||
|
page,
|
||||||
|
ENDPOINTS.SECURITY_LOGIN,
|
||||||
|
{
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
provider: 'db',
|
||||||
|
refresh: true,
|
||||||
|
},
|
||||||
|
{ allowMissingCsrf: true },
|
||||||
|
);
|
||||||
|
const loginBody = await loginResponse.json();
|
||||||
|
const accessToken = loginBody.access_token;
|
||||||
|
|
||||||
|
// Step 2: Fetch guest token using the access token.
|
||||||
|
// Uses raw page.request.post() (not apiPost) because the guest token endpoint
|
||||||
|
// requires a JWT Bearer token rather than session+CSRF auth.
|
||||||
|
const guestResponse = await page.request.post(ENDPOINTS.GUEST_TOKEN, {
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
username: 'embedded_test_user',
|
||||||
|
first_name: 'Embedded',
|
||||||
|
last_name: 'TestUser',
|
||||||
|
},
|
||||||
|
resources: [{ type: 'dashboard', id: String(dashboardId) }],
|
||||||
|
rls,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const guestBody = await guestResponse.json();
|
||||||
|
return guestBody.token;
|
||||||
|
}
|
||||||
140
superset-frontend/playwright/pages/EmbeddedPage.ts
Normal file
140
superset-frontend/playwright/pages/EmbeddedPage.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page, FrameLocator } from '@playwright/test';
|
||||||
|
import { EMBEDDED } from '../utils/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page object for the embedded dashboard test app.
|
||||||
|
*
|
||||||
|
* The test app runs on a separate origin (localhost:9000) and uses the
|
||||||
|
* @superset-ui/embedded-sdk to render a Superset dashboard in an iframe.
|
||||||
|
* Playwright's page.exposeFunction() bridges the guest token from Node.js
|
||||||
|
* into the browser page.
|
||||||
|
*/
|
||||||
|
export class EmbeddedPage {
|
||||||
|
private readonly page: Page;
|
||||||
|
|
||||||
|
private static readonly SELECTORS = {
|
||||||
|
CONTAINER: '[data-test="embedded-container"]',
|
||||||
|
IFRAME: 'iframe[title="Embedded Dashboard"]',
|
||||||
|
STATUS: '#status',
|
||||||
|
ERROR: '#error',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up the guest token bridge before navigating.
|
||||||
|
* Must be called BEFORE goto() since embedDashboard() calls fetchGuestToken
|
||||||
|
* immediately on page load.
|
||||||
|
*/
|
||||||
|
async exposeTokenFetcher(tokenFn: () => Promise<string>): Promise<void> {
|
||||||
|
await this.page.exposeFunction('__fetchGuestToken', tokenFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the embedded test app with the given parameters.
|
||||||
|
*/
|
||||||
|
async goto(params: {
|
||||||
|
uuid: string;
|
||||||
|
supersetDomain: string;
|
||||||
|
hideTitle?: boolean;
|
||||||
|
hideTab?: boolean;
|
||||||
|
hideChartControls?: boolean;
|
||||||
|
debug?: boolean;
|
||||||
|
}): Promise<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(`${EMBEDDED.APP_URL}/?${searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FrameLocator for the embedded dashboard iframe.
|
||||||
|
*/
|
||||||
|
get iframe(): FrameLocator {
|
||||||
|
return this.page.frameLocator(EmbeddedPage.SELECTORS.IFRAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the iframe to appear in the DOM.
|
||||||
|
*/
|
||||||
|
async waitForIframe(options?: { timeout?: number }): Promise<void> {
|
||||||
|
await this.page.locator(EmbeddedPage.SELECTORS.IFRAME).waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
timeout: options?.timeout ?? EMBEDDED.IFRAME_LOAD,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for dashboard content to render inside the iframe.
|
||||||
|
* Looks for the grid-container which indicates charts are loading/loaded.
|
||||||
|
*/
|
||||||
|
async waitForDashboardContent(options?: { timeout?: number }): Promise<void> {
|
||||||
|
const frame = this.iframe;
|
||||||
|
await frame
|
||||||
|
.locator('.grid-container, [data-test="grid-container"]')
|
||||||
|
.first()
|
||||||
|
.waitFor({
|
||||||
|
state: 'visible',
|
||||||
|
timeout: options?.timeout ?? EMBEDDED.DASHBOARD_RENDER,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status text from the test app.
|
||||||
|
*/
|
||||||
|
async getStatus(): Promise<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()) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the dashboard title is visible inside the iframe.
|
||||||
|
*/
|
||||||
|
async isTitleVisible(): Promise<boolean> {
|
||||||
|
const frame = this.iframe;
|
||||||
|
return frame
|
||||||
|
.locator(
|
||||||
|
'[data-test="dashboard-header-container"] [data-test="editable-title"]',
|
||||||
|
)
|
||||||
|
.isVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, Browser, BrowserContext, Page } from '@playwright/test';
|
||||||
|
import { createServer, IncomingMessage, ServerResponse, Server } from 'http';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join, extname } from 'path';
|
||||||
|
import { apiEnableEmbedding, getGuestToken } from '../../helpers/api/embedded';
|
||||||
|
import { getDashboardBySlug } from '../../helpers/api/dashboard';
|
||||||
|
import { EmbeddedPage } from '../../pages/EmbeddedPage';
|
||||||
|
import { EMBEDDED } from '../../utils/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME types for the static file server
|
||||||
|
*/
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.js': 'text/javascript',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.json': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Superset domain (Flask server) — set by CI or defaults to local dev
|
||||||
|
*/
|
||||||
|
const SUPERSET_DOMAIN = (() => {
|
||||||
|
const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
|
||||||
|
return url.replace(/\/+$/, '');
|
||||||
|
})();
|
||||||
|
|
||||||
|
const SUPERSET_BASE_URL = SUPERSET_DOMAIN.endsWith('/')
|
||||||
|
? SUPERSET_DOMAIN
|
||||||
|
: `${SUPERSET_DOMAIN}/`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the SDK bundle built from superset-embedded-sdk/
|
||||||
|
*/
|
||||||
|
const SDK_BUNDLE_PATH = join(
|
||||||
|
__dirname,
|
||||||
|
'../../../../superset-embedded-sdk/bundle/index.js',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the embedded test app static files
|
||||||
|
*/
|
||||||
|
const EMBED_APP_DIR = join(__dirname, '../../embedded-app');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a minimal static file server for the embedded test app.
|
||||||
|
* Serves HTML from embedded-app/ and the SDK bundle from superset-embedded-sdk/bundle/.
|
||||||
|
*/
|
||||||
|
function createEmbedAppServer(): Server {
|
||||||
|
return createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
const urlPath = req.url?.split('?')[0] || '/';
|
||||||
|
|
||||||
|
// Serve SDK bundle at /sdk/index.js
|
||||||
|
if (urlPath === '/sdk/index.js') {
|
||||||
|
if (!existsSync(SDK_BUNDLE_PATH)) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end(
|
||||||
|
'SDK bundle not found. Run: cd superset-embedded-sdk && npm ci && npm run build',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
||||||
|
res.end(readFileSync(SDK_BUNDLE_PATH));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve static files from embedded-app/
|
||||||
|
const filePath = join(
|
||||||
|
EMBED_APP_DIR,
|
||||||
|
urlPath === '/' ? 'index.html' : urlPath,
|
||||||
|
);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = extname(filePath);
|
||||||
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||||
|
res.writeHead(200, { 'Content-Type': contentType });
|
||||||
|
res.end(readFileSync(filePath));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a browser context authenticated as admin for API-only work
|
||||||
|
* (enabling embedding, restoring config). Caller is responsible for closing.
|
||||||
|
*/
|
||||||
|
function createAdminContext(browser: Browser): Promise<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 on a fixed port and must not run in parallel.
|
||||||
|
test.describe('Embedded Dashboard E2E', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
let server: Server;
|
||||||
|
let embedUuid: string;
|
||||||
|
let dashboardId: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a page to render the default embedded dashboard.
|
||||||
|
* Tests that need a different UUID or UI config should not use this helper.
|
||||||
|
*/
|
||||||
|
async function setupEmbeddedPage(page: Page): Promise<EmbeddedPage> {
|
||||||
|
const embeddedPage = new EmbeddedPage(page);
|
||||||
|
await embeddedPage.exposeTokenFetcher(async () =>
|
||||||
|
getGuestToken(page, dashboardId),
|
||||||
|
);
|
||||||
|
await embeddedPage.goto({
|
||||||
|
uuid: embedUuid,
|
||||||
|
supersetDomain: SUPERSET_DOMAIN,
|
||||||
|
});
|
||||||
|
await embeddedPage.waitForIframe();
|
||||||
|
await embeddedPage.waitForDashboardContent();
|
||||||
|
return embeddedPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
// Skip all tests if the SDK bundle hasn't been built
|
||||||
|
test.skip(
|
||||||
|
!existsSync(SDK_BUNDLE_PATH),
|
||||||
|
'Embedded SDK bundle not found. Build it with: cd superset-embedded-sdk && npm ci && npm run build',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start the embedded test app server
|
||||||
|
server = createEmbedAppServer();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.on('error', reject);
|
||||||
|
server.listen(EMBEDDED.APP_PORT, () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use a fresh context with auth to set up test data via API
|
||||||
|
const context = await createAdminContext(browser);
|
||||||
|
const setupPage = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find a well-known example dashboard
|
||||||
|
const dashboard = await getDashboardBySlug(setupPage, 'world_health');
|
||||||
|
if (!dashboard) {
|
||||||
|
throw new Error(
|
||||||
|
'Dashboard "world_health" not found. Ensure load_examples ran in CI setup.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dashboardId = dashboard.id;
|
||||||
|
|
||||||
|
// Enable embedding on the dashboard (empty allowed_domains = allow all)
|
||||||
|
const embedded = await apiEnableEmbedding(setupPage, dashboardId);
|
||||||
|
embedUuid = embedded.uuid;
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (server) {
|
||||||
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard renders in embedded iframe', async ({ page }) => {
|
||||||
|
const embeddedPage = await setupEmbeddedPage(page);
|
||||||
|
|
||||||
|
// Verify the iframe src points to Superset's /embedded/ endpoint
|
||||||
|
const iframeSrc = await page
|
||||||
|
.locator('iframe[title="Embedded Dashboard"]')
|
||||||
|
.getAttribute('src');
|
||||||
|
expect(iframeSrc).toContain(`/embedded/${embedUuid}`);
|
||||||
|
|
||||||
|
// Verify no errors in the test app
|
||||||
|
const error = await embeddedPage.getError();
|
||||||
|
expect(error).toBe('');
|
||||||
|
|
||||||
|
// Baseline: title should be visible when hideTitle is not set
|
||||||
|
const titleVisible = await embeddedPage.isTitleVisible();
|
||||||
|
expect(titleVisible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UI config hideTitle hides dashboard title', async ({ page }) => {
|
||||||
|
const embeddedPage = new EmbeddedPage(page);
|
||||||
|
await embeddedPage.exposeTokenFetcher(async () =>
|
||||||
|
getGuestToken(page, dashboardId),
|
||||||
|
);
|
||||||
|
await embeddedPage.goto({
|
||||||
|
uuid: embedUuid,
|
||||||
|
supersetDomain: SUPERSET_DOMAIN,
|
||||||
|
hideTitle: true,
|
||||||
|
});
|
||||||
|
await embeddedPage.waitForIframe();
|
||||||
|
await embeddedPage.waitForDashboardContent();
|
||||||
|
|
||||||
|
// The iframe URL should include uiConfig parameter
|
||||||
|
const iframeSrc = await page
|
||||||
|
.locator('iframe[title="Embedded Dashboard"]')
|
||||||
|
.getAttribute('src');
|
||||||
|
expect(iframeSrc).toContain('uiConfig=');
|
||||||
|
|
||||||
|
// Verify the title is actually hidden inside the iframe
|
||||||
|
const titleVisible = await embeddedPage.isTitleVisible();
|
||||||
|
expect(titleVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('charts render inside embedded iframe', async ({ page }) => {
|
||||||
|
const embeddedPage = await setupEmbeddedPage(page);
|
||||||
|
|
||||||
|
// Verify chart containers are present and visible in the iframe
|
||||||
|
const charts = embeddedPage.iframe.locator(
|
||||||
|
'.chart-container, [data-test="chart-container"]',
|
||||||
|
);
|
||||||
|
await expect(charts.first()).toBeVisible({
|
||||||
|
timeout: EMBEDDED.DASHBOARD_RENDER,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allowed_domains blocks unauthorized referrer', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await createAdminContext(browser);
|
||||||
|
const setupPage = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Restrict to a domain that is NOT localhost:9000
|
||||||
|
const restrictedEmbed = await apiEnableEmbedding(setupPage, dashboardId, [
|
||||||
|
'https://allowed.example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const embeddedPage = new EmbeddedPage(page);
|
||||||
|
await embeddedPage.exposeTokenFetcher(async () =>
|
||||||
|
getGuestToken(page, dashboardId),
|
||||||
|
);
|
||||||
|
await embeddedPage.goto({
|
||||||
|
uuid: restrictedEmbed.uuid,
|
||||||
|
supersetDomain: SUPERSET_DOMAIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The iframe should load but get a 403 from Superset's referrer check
|
||||||
|
await embeddedPage.waitForIframe();
|
||||||
|
|
||||||
|
// The dashboard content should NOT render (403 blocks the embedded page)
|
||||||
|
const content = embeddedPage.iframe.locator(
|
||||||
|
'.grid-container, [data-test="grid-container"]',
|
||||||
|
);
|
||||||
|
await expect(content).not.toBeVisible({ timeout: 5000 });
|
||||||
|
} finally {
|
||||||
|
// Restore the open embedding config for other tests
|
||||||
|
await apiEnableEmbedding(setupPage, dashboardId, []);
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guest token enables dashboard data access', async ({ page }) => {
|
||||||
|
const embeddedPage = new EmbeddedPage(page);
|
||||||
|
|
||||||
|
let tokenCallCount = 0;
|
||||||
|
await embeddedPage.exposeTokenFetcher(async () => {
|
||||||
|
tokenCallCount += 1;
|
||||||
|
return getGuestToken(page, dashboardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await embeddedPage.goto({
|
||||||
|
uuid: embedUuid,
|
||||||
|
supersetDomain: SUPERSET_DOMAIN,
|
||||||
|
});
|
||||||
|
await embeddedPage.waitForIframe();
|
||||||
|
await embeddedPage.waitForDashboardContent();
|
||||||
|
|
||||||
|
// The SDK should have called fetchGuestToken at least once
|
||||||
|
expect(tokenCallCount).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Verify charts are actually rendering data (not just loading spinners)
|
||||||
|
const charts = embeddedPage.iframe.locator(
|
||||||
|
'.chart-container, [data-test="chart-container"]',
|
||||||
|
);
|
||||||
|
const chartCount = await charts.count();
|
||||||
|
expect(chartCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,3 +75,18 @@ export const TIMEOUT = {
|
|||||||
*/
|
*/
|
||||||
SLOW_TEST: 60000, // 60s for tests that chain multiple slow operations
|
SLOW_TEST: 60000, // 60s for tests that chain multiple slow operations
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedded dashboard test app configuration.
|
||||||
|
* The test app is served by a Node.js http server started in the test fixture.
|
||||||
|
*/
|
||||||
|
export const EMBEDDED = {
|
||||||
|
/** Port for the embedded test app static server */
|
||||||
|
APP_PORT: 9000,
|
||||||
|
/** Full URL for the embedded test app */
|
||||||
|
APP_URL: 'http://localhost:9000',
|
||||||
|
/** Timeout for iframe to appear in the DOM */
|
||||||
|
IFRAME_LOAD: 15000, // 15s
|
||||||
|
/** Timeout for dashboard content to render inside the iframe */
|
||||||
|
DASHBOARD_RENDER: 30000, // 30s
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ FEATURE_FLAGS = {
|
|||||||
|
|
||||||
WEBDRIVER_BASEURL = "http://0.0.0.0:8081/"
|
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
|
def GET_FEATURE_FLAGS_FUNC(ff): # noqa: N802
|
||||||
ff_copy = copy(ff)
|
ff_copy = copy(ff)
|
||||||
@@ -86,6 +95,7 @@ def GET_FEATURE_FLAGS_FUNC(ff): # noqa: N802
|
|||||||
|
|
||||||
|
|
||||||
TESTING = True
|
TESTING = True
|
||||||
|
TALISMAN_ENABLED = False
|
||||||
WTF_CSRF_ENABLED = False
|
WTF_CSRF_ENABLED = False
|
||||||
|
|
||||||
FAB_ROLES = {"TestRole": [["Security", "menu_access"], ["List Users", "menu_access"]]}
|
FAB_ROLES = {"TestRole": [["Security", "menu_access"], ["List Users", "menu_access"]]}
|
||||||
|
|||||||
Reference in New Issue
Block a user