mirror of
https://github.com/apache/superset.git
synced 2026-05-28 19:25:20 +00:00
Replaces racy one-shot checks with auto-retrying assertions, asserts the referrer-block test against the deterministic 403 response (not iframe content), uses an OS-allocated port for the static test app with connection-tracked teardown, caches the JWT access token across tests, sends CSRF on the guest-token call (page.request always carries the storageState cookie, so JWT-only doesn't actually skip CSRF), and waits for a real viz element inside chart containers rather than a class that doesn't exist. Verified with --repeat-each=5 (25/25 passing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
5.4 KiB
TypeScript
164 lines
5.4 KiB
TypeScript
/**
|
|
* 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,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wait for at least one chart to finish rendering — proven by the chart
|
|
* container holding a real viz element (svg, canvas, or table) rather than
|
|
* just a loading spinner.
|
|
*/
|
|
async waitForChartRendered(options?: { timeout?: number }): Promise<void> {
|
|
const timeout = options?.timeout ?? EMBEDDED.CHART_RENDER;
|
|
await this.iframe
|
|
.locator(
|
|
'[data-test="chart-container"]:has(svg, canvas, table, [class*="big-number"])',
|
|
)
|
|
.first()
|
|
.waitFor({ state: 'visible', timeout });
|
|
}
|
|
|
|
/**
|
|
* 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()) ?? '';
|
|
}
|
|
}
|