This is an automated email from the ASF dual-hosted git repository.
jli pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new d0361cb881 test(playwright): convert and create new dataset list
playwright tests (#36196)
d0361cb881 is described below
commit d0361cb88101ccea9ff400c0aad8b42074012ac2
Author: Joe Li <[email protected]>
AuthorDate: Tue Dec 16 11:07:11 2025 -0800
test(playwright): convert and create new dataset list playwright tests
(#36196)
Co-authored-by: Claude <[email protected]>
---
.github/workflows/bashlib.sh | 13 ++
.github/workflows/superset-e2e.yml | 2 +-
.github/workflows/superset-playwright.yml | 2 +-
superset-frontend/.gitignore | 3 +
superset-frontend/playwright.config.ts | 31 ++-
.../playwright/components/core/Modal.ts | 118 ++++++++++
.../playwright/components/core/Table.ts | 102 +++++++++
.../playwright/components/core/Toast.ts | 105 +++++++++
.../playwright/components/core/index.ts | 2 +
.../components/modals/DeleteConfirmationModal.ts | 75 ++++++
.../components/modals/DuplicateDatasetModal.ts | 73 ++++++
.../components/{core => modals}/index.ts | 7 +-
superset-frontend/playwright/global-setup.ts | 93 ++++++++
.../playwright/helpers/api/database.ts | 79 +++++++
.../playwright/helpers/api/dataset.ts | 133 +++++++++++
.../playwright/helpers/api/requests.ts | 193 ++++++++++++++++
superset-frontend/playwright/pages/AuthPage.ts | 74 +++++-
.../playwright/pages/DatasetListPage.ts | 115 ++++++++++
superset-frontend/playwright/pages/ExplorePage.ts | 88 +++++++
.../playwright/tests/auth/login.spec.ts | 113 ++++-----
.../playwright/tests/experimental/README.md | 112 ++++++---
.../experimental/dataset/dataset-list.spec.ts | 254 +++++++++++++++++++++
.../playwright/utils/{urls.ts => constants.ts} | 29 ++-
superset-frontend/playwright/utils/urls.ts | 11 +
24 files changed, 1724 insertions(+), 103 deletions(-)
diff --git a/.github/workflows/bashlib.sh b/.github/workflows/bashlib.sh
index 1289d07259..362f39a474 100644
--- a/.github/workflows/bashlib.sh
+++ b/.github/workflows/bashlib.sh
@@ -117,6 +117,19 @@ testdata() {
say "::endgroup::"
}
+playwright_testdata() {
+ cd "$GITHUB_WORKSPACE"
+ say "::group::Load all examples for Playwright tests"
+ # must specify PYTHONPATH to make `tests.superset_test_config` importable
+ export PYTHONPATH="$GITHUB_WORKSPACE"
+ pip install -e .
+ superset db upgrade
+ superset load_test_users
+ superset load_examples
+ superset init
+ say "::endgroup::"
+}
+
celery-worker() {
cd "$GITHUB_WORKSPACE"
say "::group::Start Celery worker"
diff --git a/.github/workflows/superset-e2e.yml
b/.github/workflows/superset-e2e.yml
index 6af3a92a97..655a3da7e2 100644
--- a/.github/workflows/superset-e2e.yml
+++ b/.github/workflows/superset-e2e.yml
@@ -223,7 +223,7 @@ jobs:
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
- run: testdata
+ run: playwright_testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v6
diff --git a/.github/workflows/superset-playwright.yml
b/.github/workflows/superset-playwright.yml
index ef725141b4..b2e2dbf6a9 100644
--- a/.github/workflows/superset-playwright.yml
+++ b/.github/workflows/superset-playwright.yml
@@ -97,7 +97,7 @@ jobs:
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
- run: testdata
+ run: playwright_testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v6
diff --git a/superset-frontend/.gitignore b/superset-frontend/.gitignore
index a7027112bc..359cf1ea87 100644
--- a/superset-frontend/.gitignore
+++ b/superset-frontend/.gitignore
@@ -1,6 +1,9 @@
coverage/*
cypress/screenshots
cypress/videos
+playwright/.auth
+playwright-report/
+test-results/
src/temp
.temp_cache/
.tsbuildinfo
diff --git a/superset-frontend/playwright.config.ts
b/superset-frontend/playwright.config.ts
index 585ccbb96f..c4fcf3e96f 100644
--- a/superset-frontend/playwright.config.ts
+++ b/superset-frontend/playwright.config.ts
@@ -33,6 +33,9 @@ export default defineConfig({
? undefined
: '**/experimental/**',
+ // Global setup - authenticate once before all tests
+ globalSetup: './playwright/global-setup.ts',
+
// Timeout settings
timeout: 30000,
expect: { timeout: 8000 },
@@ -60,7 +63,11 @@ export default defineConfig({
// Global test setup
use: {
// Use environment variable for base URL in CI, default to localhost:8088
for local
- baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088',
+ // Normalize to always end with '/' to prevent URL resolution issues with
APP_PREFIX
+ baseURL: (() => {
+ const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
+ return url.endsWith('/') ? url : `${url}/`;
+ })(),
// Browser settings
headless: !!process.env.CI,
@@ -77,10 +84,32 @@ export default defineConfig({
projects: [
{
+ // Default project - uses global authentication for speed
+ // E2E tests login once via global-setup.ts and reuse auth state
+ // Explicitly ignore auth tests (they run in chromium-unauth project)
+ // Also respect the global experimental testIgnore setting
name: 'chromium',
+ testIgnore: [
+ '**/tests/auth/**/*.spec.ts',
+ ...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
+ ],
+ use: {
+ browserName: 'chromium',
+ testIdAttribute: 'data-test',
+ // Reuse authentication state from global setup (fast E2E tests)
+ storageState: 'playwright/.auth/user.json',
+ },
+ },
+ {
+ // Separate project for unauthenticated tests (login, signup, etc.)
+ // These tests use beforeEach for per-test navigation - no global auth
+ // This hybrid approach: simple auth tests, fast E2E tests
+ name: 'chromium-unauth',
+ testMatch: '**/tests/auth/**/*.spec.ts',
use: {
browserName: 'chromium',
testIdAttribute: 'data-test',
+ // No storageState = clean browser with no cached cookies
},
},
],
diff --git a/superset-frontend/playwright/components/core/Modal.ts
b/superset-frontend/playwright/components/core/Modal.ts
new file mode 100644
index 0000000000..2b91369113
--- /dev/null
+++ b/superset-frontend/playwright/components/core/Modal.ts
@@ -0,0 +1,118 @@
+/**
+ * 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 { Locator, Page } from '@playwright/test';
+
+/**
+ * Base Modal component for Ant Design modals.
+ * Provides minimal primitives - extend this for specific modal types.
+ * Add methods to this class only when multiple modal types need them (YAGNI).
+ *
+ * @example
+ * class DeleteConfirmationModal extends Modal {
+ * async clickDelete(): Promise<void> {
+ * await this.footer.locator('button', { hasText: 'Delete' }).click();
+ * }
+ * }
+ */
+export class Modal {
+ protected readonly page: Page;
+ protected readonly modalSelector: string;
+
+ // Ant Design modal structure selectors (shared by all modal types)
+ protected static readonly BASE_SELECTORS = {
+ FOOTER: '.ant-modal-footer',
+ BODY: '.ant-modal-body',
+ };
+
+ constructor(page: Page, modalSelector = '[role="dialog"]') {
+ this.page = page;
+ this.modalSelector = modalSelector;
+ }
+
+ /**
+ * Gets the modal element locator
+ */
+ get element(): Locator {
+ return this.page.locator(this.modalSelector);
+ }
+
+ /**
+ * Gets the modal footer locator (contains action buttons)
+ */
+ get footer(): Locator {
+ return this.element.locator(Modal.BASE_SELECTORS.FOOTER);
+ }
+
+ /**
+ * Gets the modal body locator (contains content)
+ */
+ get body(): Locator {
+ return this.element.locator(Modal.BASE_SELECTORS.BODY);
+ }
+
+ /**
+ * Gets a footer button by text content (private helper)
+ * @param buttonText - The text content of the button
+ */
+ private getFooterButton(buttonText: string): Locator {
+ return this.footer.getByRole('button', { name: buttonText, exact: true });
+ }
+
+ /**
+ * Clicks a footer button by text content
+ * @param buttonText - The text content of the button to click
+ * @param options - Optional click options
+ */
+ protected async clickFooterButton(
+ buttonText: string,
+ options?: { timeout?: number; force?: boolean; delay?: number },
+ ): Promise<void> {
+ await this.getFooterButton(buttonText).click(options);
+ }
+
+ /**
+ * Waits for the modal to become visible
+ * @param options - Optional wait options
+ */
+ async waitForVisible(options?: { timeout?: number }): Promise<void> {
+ await this.element.waitFor({ state: 'visible', ...options });
+ }
+
+ /**
+ * Waits for the modal to be fully ready for interaction.
+ * This includes waiting for the modal dialog to be visible AND for React to
finish
+ * rendering the modal content. Use this before interacting with modal
elements
+ * to avoid race conditions with React state updates.
+ *
+ * @param options - Optional wait options
+ */
+ async waitForReady(options?: { timeout?: number }): Promise<void> {
+ await this.waitForVisible(options);
+ await this.body.waitFor({ state: 'visible', ...options });
+ }
+
+ /**
+ * Waits for the modal to be hidden
+ * @param options - Optional wait options
+ */
+ async waitForHidden(options?: { timeout?: number }): Promise<void> {
+ await this.element.waitFor({ state: 'hidden', ...options });
+ }
+}
diff --git a/superset-frontend/playwright/components/core/Table.ts
b/superset-frontend/playwright/components/core/Table.ts
new file mode 100644
index 0000000000..d3f7a67b14
--- /dev/null
+++ b/superset-frontend/playwright/components/core/Table.ts
@@ -0,0 +1,102 @@
+/**
+ * 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 { Locator, Page } from '@playwright/test';
+
+/**
+ * Table component for Superset ListView tables.
+ */
+export class Table {
+ private readonly page: Page;
+ private readonly tableSelector: string;
+
+ private static readonly SELECTORS = {
+ TABLE_ROW: '[data-test="table-row"]',
+ };
+
+ constructor(page: Page, tableSelector = '[data-test="listview-table"]') {
+ this.page = page;
+ this.tableSelector = tableSelector;
+ }
+
+ /**
+ * Gets the table element locator
+ */
+ get element(): Locator {
+ return this.page.locator(this.tableSelector);
+ }
+
+ /**
+ * Gets a table row by exact text match in the first cell (dataset name
column).
+ * Uses exact match to avoid substring collisions (e.g.,
'members_channels_2' vs 'duplicate_members_channels_2_123').
+ *
+ * Note: Returns a Locator that will auto-wait when used in assertions or
actions.
+ * If row doesn't exist, operations on the locator will timeout with clear
error.
+ *
+ * @param rowText - Exact text to find in the row's first cell
+ * @returns Locator for the matching row
+ */
+ getRow(rowText: string): Locator {
+ return this.element.locator(Table.SELECTORS.TABLE_ROW).filter({
+ has: this.page.getByRole('cell', { name: rowText, exact: true }),
+ });
+ }
+
+ /**
+ * Clicks a link within a specific row
+ * @param rowText - Text to identify the row
+ * @param linkSelector - Selector for the link within the row
+ */
+ async clickRowLink(rowText: string, linkSelector: string): Promise<void> {
+ const row = this.getRow(rowText);
+ await row.locator(linkSelector).click();
+ }
+
+ /**
+ * Waits for the table to be visible
+ * @param options - Optional wait options
+ */
+ async waitForVisible(options?: { timeout?: number }): Promise<void> {
+ await this.element.waitFor({ state: 'visible', ...options });
+ }
+
+ /**
+ * Clicks an action button in a row by selector
+ * @param rowText - Text to identify the row
+ * @param selector - CSS selector for the action element
+ */
+ async clickRowAction(rowText: string, selector: string): Promise<void> {
+ const row = this.getRow(rowText);
+ const actionButton = row.locator(selector);
+
+ const count = await actionButton.count();
+ if (count === 0) {
+ throw new Error(
+ `No action button found with selector "${selector}" in row
"${rowText}"`,
+ );
+ }
+ if (count > 1) {
+ throw new Error(
+ `Multiple action buttons (${count}) found with selector "${selector}"
in row "${rowText}". Use more specific selector.`,
+ );
+ }
+
+ await actionButton.click();
+ }
+}
diff --git a/superset-frontend/playwright/components/core/Toast.ts
b/superset-frontend/playwright/components/core/Toast.ts
new file mode 100644
index 0000000000..a291e03c0d
--- /dev/null
+++ b/superset-frontend/playwright/components/core/Toast.ts
@@ -0,0 +1,105 @@
+/**
+ * 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, Locator } from '@playwright/test';
+
+export type ToastType = 'success' | 'danger' | 'warning' | 'info';
+
+const SELECTORS = {
+ CONTAINER: '[data-test="toast-container"][role="alert"]',
+ CONTENT: '.toast__content',
+ CLOSE_BUTTON: '[data-test="close-button"]',
+} as const;
+
+/**
+ * Toast notification component
+ * Handles success, danger, warning, and info toasts
+ */
+export class Toast {
+ private page: Page;
+ private container: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.container = page.locator(SELECTORS.CONTAINER);
+ }
+
+ /**
+ * Get the toast container locator
+ */
+ get(): Locator {
+ return this.container;
+ }
+
+ /**
+ * Get the toast message text
+ */
+ getMessage(): Locator {
+ return this.container.locator(SELECTORS.CONTENT);
+ }
+
+ /**
+ * Wait for a toast to appear
+ */
+ async waitForVisible(): Promise<void> {
+ await this.container.waitFor({ state: 'visible' });
+ }
+
+ /**
+ * Wait for toast to disappear
+ */
+ async waitForHidden(): Promise<void> {
+ await this.container.waitFor({ state: 'hidden' });
+ }
+
+ /**
+ * Get a success toast
+ */
+ getSuccess(): Locator {
+ return this.page.locator(`${SELECTORS.CONTAINER}.toast--success`);
+ }
+
+ /**
+ * Get a danger/error toast
+ */
+ getDanger(): Locator {
+ return this.page.locator(`${SELECTORS.CONTAINER}.toast--danger`);
+ }
+
+ /**
+ * Get a warning toast
+ */
+ getWarning(): Locator {
+ return this.page.locator(`${SELECTORS.CONTAINER}.toast--warning`);
+ }
+
+ /**
+ * Get an info toast
+ */
+ getInfo(): Locator {
+ return this.page.locator(`${SELECTORS.CONTAINER}.toast--info`);
+ }
+
+ /**
+ * Close the toast by clicking the close button
+ */
+ async close(): Promise<void> {
+ await this.container.locator(SELECTORS.CLOSE_BUTTON).click();
+ }
+}
diff --git a/superset-frontend/playwright/components/core/index.ts
b/superset-frontend/playwright/components/core/index.ts
index 3d99379e99..82a26c2b69 100644
--- a/superset-frontend/playwright/components/core/index.ts
+++ b/superset-frontend/playwright/components/core/index.ts
@@ -21,3 +21,5 @@
export { Button } from './Button';
export { Form } from './Form';
export { Input } from './Input';
+export { Modal } from './Modal';
+export { Table } from './Table';
diff --git
a/superset-frontend/playwright/components/modals/DeleteConfirmationModal.ts
b/superset-frontend/playwright/components/modals/DeleteConfirmationModal.ts
new file mode 100644
index 0000000000..44dca9e6b2
--- /dev/null
+++ b/superset-frontend/playwright/components/modals/DeleteConfirmationModal.ts
@@ -0,0 +1,75 @@
+/**
+ * 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 { Modal, Input } from '../core';
+
+/**
+ * Delete confirmation modal that requires typing "DELETE" to confirm.
+ * Used throughout Superset for destructive delete operations.
+ *
+ * Provides primitives for tests to compose deletion flows.
+ */
+export class DeleteConfirmationModal extends Modal {
+ private static readonly SELECTORS = {
+ CONFIRMATION_INPUT: 'input[type="text"]',
+ };
+
+ /**
+ * Gets the confirmation input component
+ */
+ private get confirmationInput(): Input {
+ return new Input(
+ this.page,
+ this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT),
+ );
+ }
+
+ /**
+ * Fills the confirmation input with the specified text.
+ *
+ * @param confirmationText - The text to type
+ * @param options - Optional fill options (timeout, force)
+ *
+ * @example
+ * const deleteModal = new DeleteConfirmationModal(page);
+ * await deleteModal.waitForVisible();
+ * await deleteModal.fillConfirmationInput('DELETE');
+ * await deleteModal.clickDelete();
+ * await deleteModal.waitForHidden();
+ */
+ async fillConfirmationInput(
+ confirmationText: string,
+ options?: { timeout?: number; force?: boolean },
+ ): Promise<void> {
+ await this.confirmationInput.fill(confirmationText, options);
+ }
+
+ /**
+ * Clicks the Delete button in the footer
+ *
+ * @param options - Optional click options (timeout, force, delay)
+ */
+ async clickDelete(options?: {
+ timeout?: number;
+ force?: boolean;
+ delay?: number;
+ }): Promise<void> {
+ await this.clickFooterButton('Delete', options);
+ }
+}
diff --git
a/superset-frontend/playwright/components/modals/DuplicateDatasetModal.ts
b/superset-frontend/playwright/components/modals/DuplicateDatasetModal.ts
new file mode 100644
index 0000000000..68a4fdb132
--- /dev/null
+++ b/superset-frontend/playwright/components/modals/DuplicateDatasetModal.ts
@@ -0,0 +1,73 @@
+/**
+ * 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 { Modal, Input } from '../core';
+
+/**
+ * Duplicate dataset modal that requires entering a new dataset name.
+ * Used for duplicating virtual datasets with custom SQL.
+ */
+export class DuplicateDatasetModal extends Modal {
+ private static readonly SELECTORS = {
+ NAME_INPUT: '[data-test="duplicate-modal-input"]',
+ };
+
+ /**
+ * Gets the new dataset name input component
+ */
+ private get nameInput(): Input {
+ return new Input(
+ this.page,
+ this.body.locator(DuplicateDatasetModal.SELECTORS.NAME_INPUT),
+ );
+ }
+
+ /**
+ * Fills the new dataset name input
+ *
+ * @param datasetName - The new name for the duplicated dataset
+ * @param options - Optional fill options (timeout, force)
+ *
+ * @example
+ * const duplicateModal = new DuplicateDatasetModal(page);
+ * await duplicateModal.waitForVisible();
+ * await duplicateModal.fillDatasetName('my_dataset_copy');
+ * await duplicateModal.clickDuplicate();
+ * await duplicateModal.waitForHidden();
+ */
+ async fillDatasetName(
+ datasetName: string,
+ options?: { timeout?: number; force?: boolean },
+ ): Promise<void> {
+ await this.nameInput.fill(datasetName, options);
+ }
+
+ /**
+ * Clicks the Duplicate button in the footer
+ *
+ * @param options - Optional click options (timeout, force, delay)
+ */
+ async clickDuplicate(options?: {
+ timeout?: number;
+ force?: boolean;
+ delay?: number;
+ }): Promise<void> {
+ await this.clickFooterButton('Duplicate', options);
+ }
+}
diff --git a/superset-frontend/playwright/components/core/index.ts
b/superset-frontend/playwright/components/modals/index.ts
similarity index 82%
copy from superset-frontend/playwright/components/core/index.ts
copy to superset-frontend/playwright/components/modals/index.ts
index 3d99379e99..83356921ad 100644
--- a/superset-frontend/playwright/components/core/index.ts
+++ b/superset-frontend/playwright/components/modals/index.ts
@@ -17,7 +17,6 @@
* under the License.
*/
-// Core Playwright Components for Superset
-export { Button } from './Button';
-export { Form } from './Form';
-export { Input } from './Input';
+// Specific modal implementations
+export { DeleteConfirmationModal } from './DeleteConfirmationModal';
+export { DuplicateDatasetModal } from './DuplicateDatasetModal';
diff --git a/superset-frontend/playwright/global-setup.ts
b/superset-frontend/playwright/global-setup.ts
new file mode 100644
index 0000000000..f06f610fe0
--- /dev/null
+++ b/superset-frontend/playwright/global-setup.ts
@@ -0,0 +1,93 @@
+/**
+ * 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 {
+ chromium,
+ FullConfig,
+ Browser,
+ BrowserContext,
+} from '@playwright/test';
+import { mkdir } from 'fs/promises';
+import { dirname } from 'path';
+import { AuthPage } from './pages/AuthPage';
+import { TIMEOUT } from './utils/constants';
+
+/**
+ * Global setup function that runs once before all tests.
+ * Authenticates as admin user and saves the authentication state
+ * to be reused by tests in the 'chromium' project (E2E tests).
+ *
+ * Auth tests (chromium-unauth project) don't use this - they login
+ * per-test via beforeEach for isolation and simplicity.
+ */
+async function globalSetup(config: FullConfig) {
+ // Get baseURL with fallback to default
+ // FullConfig.use doesn't exist in the type - baseURL is only in
projects[0].use
+ const baseURL = config.projects[0]?.use?.baseURL || 'http://localhost:8088';
+
+ // Test credentials - can be overridden via environment variables
+ const adminUsername = process.env.PLAYWRIGHT_ADMIN_USERNAME || 'admin';
+ const adminPassword = process.env.PLAYWRIGHT_ADMIN_PASSWORD || 'general';
+
+ console.log('[Global Setup] Authenticating as admin user...');
+
+ let browser: Browser | null = null;
+ let context: BrowserContext | null = null;
+
+ try {
+ // Launch browser
+ browser = await chromium.launch();
+ } catch (error) {
+ console.error('[Global Setup] Failed to launch browser:', error);
+ throw new Error('Browser launch failed - check Playwright installation');
+ }
+
+ try {
+ context = await browser.newContext({ baseURL });
+ const page = await context.newPage();
+
+ // Use AuthPage to handle login logic (DRY principle)
+ const authPage = new AuthPage(page);
+ await authPage.goto();
+ await authPage.waitForLoginForm();
+ await authPage.loginWithCredentials(adminUsername, adminPassword);
+ // Use longer timeout for global setup (cold CI starts may exceed
PAGE_LOAD timeout)
+ await authPage.waitForLoginSuccess({ timeout: TIMEOUT.GLOBAL_SETUP });
+
+ // Save authentication state for all tests to reuse
+ const authStatePath = 'playwright/.auth/user.json';
+ await mkdir(dirname(authStatePath), { recursive: true });
+ await context.storageState({
+ path: authStatePath,
+ });
+
+ console.log(
+ '[Global Setup] Authentication successful - state saved to
playwright/.auth/user.json',
+ );
+ } catch (error) {
+ console.error('[Global Setup] Authentication failed:', error);
+ throw error;
+ } finally {
+ // Ensure cleanup even if auth fails
+ if (context) await context.close();
+ if (browser) await browser.close();
+ }
+}
+
+export default globalSetup;
diff --git a/superset-frontend/playwright/helpers/api/database.ts
b/superset-frontend/playwright/helpers/api/database.ts
new file mode 100644
index 0000000000..31955393ca
--- /dev/null
+++ b/superset-frontend/playwright/helpers/api/database.ts
@@ -0,0 +1,79 @@
+/**
+ * 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, APIResponse } from '@playwright/test';
+import { apiPost, apiDelete, ApiRequestOptions } from './requests';
+
+const ENDPOINTS = {
+ DATABASE: 'api/v1/database/',
+} as const;
+
+/**
+ * TypeScript interface for database creation API payload
+ * Provides compile-time safety for required fields
+ */
+export interface DatabaseCreatePayload {
+ database_name: string;
+ engine: string;
+ configuration_method?: string;
+ engine_information?: {
+ disable_ssh_tunneling?: boolean;
+ supports_dynamic_catalog?: boolean;
+ supports_file_upload?: boolean;
+ supports_oauth2?: boolean;
+ };
+ driver?: string;
+ sqlalchemy_uri_placeholder?: string;
+ extra?: string;
+ expose_in_sqllab?: boolean;
+ catalog?: Array<{ name: string; value: string }>;
+ parameters?: {
+ service_account_info?: string;
+ catalog?: Record<string, string>;
+ };
+ masked_encrypted_extra?: string;
+ impersonate_user?: boolean;
+}
+
+/**
+ * POST request to create a database connection
+ * @param page - Playwright page instance (provides authentication context)
+ * @param requestBody - Database configuration object with type safety
+ * @returns API response from database creation
+ */
+export async function apiPostDatabase(
+ page: Page,
+ requestBody: DatabaseCreatePayload,
+): Promise<APIResponse> {
+ return apiPost(page, ENDPOINTS.DATABASE, requestBody);
+}
+
+/**
+ * DELETE request to remove a database connection
+ * @param page - Playwright page instance (provides authentication context)
+ * @param databaseId - ID of the database to delete
+ * @returns API response from database deletion
+ */
+export async function apiDeleteDatabase(
+ page: Page,
+ databaseId: number,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ return apiDelete(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
+}
diff --git a/superset-frontend/playwright/helpers/api/dataset.ts
b/superset-frontend/playwright/helpers/api/dataset.ts
new file mode 100644
index 0000000000..0903df7adc
--- /dev/null
+++ b/superset-frontend/playwright/helpers/api/dataset.ts
@@ -0,0 +1,133 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Page, APIResponse } from '@playwright/test';
+import rison from 'rison';
+import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
+
+export const ENDPOINTS = {
+ DATASET: 'api/v1/dataset/',
+} as const;
+
+/**
+ * TypeScript interface for dataset creation API payload
+ * Provides compile-time safety for required fields
+ */
+export interface DatasetCreatePayload {
+ database: number;
+ catalog: string | null;
+ schema: string;
+ table_name: string;
+}
+
+/**
+ * TypeScript interface for dataset API response
+ * Represents the shape of dataset data returned from the API
+ */
+export interface DatasetResult {
+ id: number;
+ table_name: string;
+ sql?: string;
+ schema?: string;
+ database: {
+ id: number;
+ database_name: string;
+ };
+ owners?: Array<{ id: number }>;
+ dataset_type?: 'physical' | 'virtual';
+}
+
+/**
+ * POST request to create a dataset
+ * @param page - Playwright page instance (provides authentication context)
+ * @param requestBody - Dataset configuration object (database, schema,
table_name)
+ * @returns API response from dataset creation
+ */
+export async function apiPostDataset(
+ page: Page,
+ requestBody: DatasetCreatePayload,
+): Promise<APIResponse> {
+ return apiPost(page, ENDPOINTS.DATASET, requestBody);
+}
+
+/**
+ * Get a dataset by its table name
+ * @param page - Playwright page instance (provides authentication context)
+ * @param tableName - The table_name to search for
+ * @returns Dataset object if found, null if not found
+ */
+export async function getDatasetByName(
+ page: Page,
+ tableName: string,
+): Promise<DatasetResult | null> {
+ // Use Superset's filter API to search by table_name
+ const filter = {
+ filters: [
+ {
+ col: 'table_name',
+ opr: 'eq',
+ value: tableName,
+ },
+ ],
+ };
+ const queryParam = rison.encode(filter);
+ // Use failOnStatusCode: false so we return null instead of throwing on
errors
+ const response = await apiGet(page, `${ENDPOINTS.DATASET}?q=${queryParam}`, {
+ failOnStatusCode: false,
+ });
+
+ if (!response.ok()) {
+ return null;
+ }
+
+ const body = await response.json();
+ if (body.result && body.result.length > 0) {
+ return body.result[0] as DatasetResult;
+ }
+
+ return null;
+}
+
+/**
+ * GET request to fetch a dataset's details
+ * @param page - Playwright page instance (provides authentication context)
+ * @param datasetId - ID of the dataset to fetch
+ * @returns API response with dataset details
+ */
+export async function apiGetDataset(
+ page: Page,
+ datasetId: number,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ return apiGet(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
+}
+
+/**
+ * DELETE request to remove a dataset
+ * @param page - Playwright page instance (provides authentication context)
+ * @param datasetId - ID of the dataset to delete
+ * @returns API response from dataset deletion
+ */
+export async function apiDeleteDataset(
+ page: Page,
+ datasetId: number,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ return apiDelete(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
+}
diff --git a/superset-frontend/playwright/helpers/api/requests.ts
b/superset-frontend/playwright/helpers/api/requests.ts
new file mode 100644
index 0000000000..9705d5e9b9
--- /dev/null
+++ b/superset-frontend/playwright/helpers/api/requests.ts
@@ -0,0 +1,193 @@
+/**
+ * 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, APIResponse } from '@playwright/test';
+
+export interface ApiRequestOptions {
+ headers?: Record<string, string>;
+ params?: Record<string, string>;
+ failOnStatusCode?: boolean;
+ allowMissingCsrf?: boolean;
+}
+
+/**
+ * Get base URL for Referer header
+ * Reads from environment variable configured in playwright.config.ts
+ * Preserves full base URL including path prefix (e.g., /app/prefix/)
+ * Normalizes to always end with '/' for consistent URL resolution
+ */
+function getBaseUrl(): string {
+ // Use environment variable which includes path prefix if configured
+ // Normalize to always end with '/' (matches playwright.config.ts
normalization)
+ const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
+ return url.endsWith('/') ? url : `${url}/`;
+}
+
+interface CsrfResult {
+ token: string;
+ error?: string;
+}
+
+/**
+ * Get CSRF token from the API endpoint
+ * 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> {
+ try {
+ const response = await page.request.get('api/v1/security/csrf_token/', {
+ failOnStatusCode: false,
+ });
+
+ if (!response.ok()) {
+ return {
+ token: '',
+ error: `HTTP ${response.status()} ${response.statusText()}`,
+ };
+ }
+
+ const json = await response.json();
+ return { token: json.result || '' };
+ } catch (error) {
+ return { token: '', error: String(error) };
+ }
+}
+
+/**
+ * Build headers for mutation requests (POST, PUT, PATCH, DELETE)
+ * Includes CSRF token and Referer for Flask-WTF CSRFProtect
+ */
+async function buildHeaders(
+ page: Page,
+ options?: ApiRequestOptions,
+): Promise<Record<string, string>> {
+ const { token: csrfToken, error: csrfError } = await getCsrfToken(page);
+ const headers: Record<string, string> = {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ };
+
+ // Include CSRF token and Referer for Flask-WTF CSRFProtect
+ if (csrfToken) {
+ headers['X-CSRFToken'] = csrfToken;
+ headers['Referer'] = getBaseUrl();
+ } else if (!options?.allowMissingCsrf) {
+ const errorDetail = csrfError ? ` (${csrfError})` : '';
+ throw new Error(
+ `Missing CSRF token${errorDetail} - mutation requests require
authentication. ` +
+ 'Ensure global authentication completed or test has valid session.',
+ );
+ }
+
+ return headers;
+}
+
+/**
+ * Send a GET request
+ * Uses page.request to automatically include browser authentication
+ */
+export async function apiGet(
+ page: Page,
+ url: string,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ return page.request.get(url, {
+ headers: options?.headers,
+ params: options?.params,
+ failOnStatusCode: options?.failOnStatusCode ?? true,
+ });
+}
+
+/**
+ * Send a POST request
+ * Uses page.request to automatically include browser authentication
+ */
+export async function apiPost(
+ page: Page,
+ url: string,
+ data?: unknown,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ const headers = await buildHeaders(page, options);
+
+ return page.request.post(url, {
+ data,
+ headers,
+ params: options?.params,
+ failOnStatusCode: options?.failOnStatusCode ?? true,
+ });
+}
+
+/**
+ * Send a PUT request
+ * Uses page.request to automatically include browser authentication
+ */
+export async function apiPut(
+ page: Page,
+ url: string,
+ data?: unknown,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ const headers = await buildHeaders(page, options);
+
+ return page.request.put(url, {
+ data,
+ headers,
+ params: options?.params,
+ failOnStatusCode: options?.failOnStatusCode ?? true,
+ });
+}
+
+/**
+ * Send a PATCH request
+ * Uses page.request to automatically include browser authentication
+ */
+export async function apiPatch(
+ page: Page,
+ url: string,
+ data?: unknown,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ const headers = await buildHeaders(page, options);
+
+ return page.request.patch(url, {
+ data,
+ headers,
+ params: options?.params,
+ failOnStatusCode: options?.failOnStatusCode ?? true,
+ });
+}
+
+/**
+ * Send a DELETE request
+ * Uses page.request to automatically include browser authentication
+ */
+export async function apiDelete(
+ page: Page,
+ url: string,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ const headers = await buildHeaders(page, options);
+
+ return page.request.delete(url, {
+ headers,
+ params: options?.params,
+ failOnStatusCode: options?.failOnStatusCode ?? true,
+ });
+}
diff --git a/superset-frontend/playwright/pages/AuthPage.ts
b/superset-frontend/playwright/pages/AuthPage.ts
index a925ceaae8..2cb5157e14 100644
--- a/superset-frontend/playwright/pages/AuthPage.ts
+++ b/superset-frontend/playwright/pages/AuthPage.ts
@@ -17,9 +17,10 @@
* under the License.
*/
-import { Page, Response } from '@playwright/test';
+import { Page, Response, Cookie } from '@playwright/test';
import { Form } from '../components/core';
import { URL } from '../utils/urls';
+import { TIMEOUT } from '../utils/constants';
export class AuthPage {
private readonly page: Page;
@@ -56,7 +57,7 @@ export class AuthPage {
* Wait for login form to be visible
*/
async waitForLoginForm(): Promise<void> {
- await this.loginForm.waitForVisible({ timeout: 5000 });
+ await this.loginForm.waitForVisible({ timeout: TIMEOUT.FORM_LOAD });
}
/**
@@ -83,6 +84,67 @@ export class AuthPage {
await loginButton.click();
}
+ /**
+ * Wait for successful login by verifying the login response and session
cookie.
+ * Call this after loginWithCredentials to ensure authentication completed.
+ *
+ * This does NOT assume a specific landing page (which is configurable).
+ * Instead it:
+ * 1. Checks if session cookie already exists (guards against race condition)
+ * 2. Waits for POST /login/ response with redirect status
+ * 3. Polls for session cookie to appear
+ *
+ * @param options - Optional wait options
+ */
+ async waitForLoginSuccess(options?: { timeout?: number }): Promise<void> {
+ const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
+ const startTime = Date.now();
+
+ // 1. Guard: Check if session cookie already exists (race condition
protection)
+ const existingCookie = await this.getSessionCookie();
+ if (existingCookie?.value) {
+ // Already authenticated - login completed before we started waiting
+ return;
+ }
+
+ // 2. Wait for POST /login/ response (bounded by caller's timeout)
+ const loginResponse = await this.page.waitForResponse(
+ response =>
+ response.url().includes('/login/') &&
+ response.request().method() === 'POST',
+ { timeout },
+ );
+
+ // 3. Verify it's a redirect (3xx status code indicates successful login)
+ const status = loginResponse.status();
+ if (status < 300 || status >= 400) {
+ throw new Error(`Login failed: expected redirect (3xx), got ${status}`);
+ }
+
+ // 4. Poll for session cookie to appear (HttpOnly cookie, not accessible
via document.cookie)
+ // Use page.context().cookies() since session cookie is HttpOnly
+ const pollInterval = 500; // 500ms instead of 100ms for less chattiness
+ while (true) {
+ const remaining = timeout - (Date.now() - startTime);
+ if (remaining <= 0) {
+ break; // Timeout exceeded
+ }
+
+ const sessionCookie = await this.getSessionCookie();
+ if (sessionCookie && sessionCookie.value) {
+ // Success - session cookie has landed
+ return;
+ }
+
+ await this.page.waitForTimeout(Math.min(pollInterval, remaining));
+ }
+
+ const currentUrl = await this.page.url();
+ throw new Error(
+ `Login timeout: session cookie did not appear within ${timeout}ms.
Current URL: ${currentUrl}`,
+ );
+ }
+
/**
* Get current page URL
*/
@@ -93,9 +155,9 @@ export class AuthPage {
/**
* Get the session cookie specifically
*/
- async getSessionCookie(): Promise<{ name: string; value: string } | null> {
+ async getSessionCookie(): Promise<Cookie | null> {
const cookies = await this.page.context().cookies();
- return cookies.find((c: any) => c.name === 'session') || null;
+ return cookies.find(c => c.name === 'session') || null;
}
/**
@@ -106,7 +168,7 @@ export class AuthPage {
selector => this.page.locator(selector).isVisible(),
);
const visibilityResults = await Promise.all(visibilityPromises);
- return visibilityResults.some((isVisible: any) => isVisible);
+ return visibilityResults.some(isVisible => isVisible);
}
/**
@@ -114,7 +176,7 @@ export class AuthPage {
*/
async waitForLoginRequest(): Promise<Response> {
return this.page.waitForResponse(
- (response: any) =>
+ response =>
response.url().includes('/login/') &&
response.request().method() === 'POST',
);
diff --git a/superset-frontend/playwright/pages/DatasetListPage.ts
b/superset-frontend/playwright/pages/DatasetListPage.ts
new file mode 100644
index 0000000000..a7b6af75a1
--- /dev/null
+++ b/superset-frontend/playwright/pages/DatasetListPage.ts
@@ -0,0 +1,115 @@
+/**
+ * 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, Locator } from '@playwright/test';
+import { Table } from '../components/core';
+import { URL } from '../utils/urls';
+
+/**
+ * Dataset List Page object.
+ */
+export class DatasetListPage {
+ private readonly page: Page;
+ private readonly table: Table;
+
+ private static readonly SELECTORS = {
+ DATASET_LINK: '[data-test="internal-link"]',
+ DELETE_ACTION: '.action-button svg[data-icon="delete"]',
+ EXPORT_ACTION: '.action-button svg[data-icon="upload"]',
+ DUPLICATE_ACTION: '.action-button svg[data-icon="copy"]',
+ } as const;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.table = new Table(page);
+ }
+
+ /**
+ * Navigate to the dataset list page
+ */
+ async goto(): Promise<void> {
+ await this.page.goto(URL.DATASET_LIST);
+ }
+
+ /**
+ * Wait for the table to load
+ * @param options - Optional wait options
+ */
+ async waitForTableLoad(options?: { timeout?: number }): Promise<void> {
+ await this.table.waitForVisible(options);
+ }
+
+ /**
+ * Gets a dataset row locator by name.
+ * Returns a Locator that tests can use with expect().toBeVisible(), etc.
+ *
+ * @param datasetName - The name of the dataset
+ * @returns Locator for the dataset row
+ *
+ * @example
+ * await expect(datasetListPage.getDatasetRow('birth_names')).toBeVisible();
+ */
+ getDatasetRow(datasetName: string): Locator {
+ return this.table.getRow(datasetName);
+ }
+
+ /**
+ * Clicks on a dataset name to navigate to Explore
+ * @param datasetName - The name of the dataset to click
+ */
+ async clickDatasetName(datasetName: string): Promise<void> {
+ await this.table.clickRowLink(
+ datasetName,
+ DatasetListPage.SELECTORS.DATASET_LINK,
+ );
+ }
+
+ /**
+ * Clicks the delete action button for a dataset
+ * @param datasetName - The name of the dataset to delete
+ */
+ async clickDeleteAction(datasetName: string): Promise<void> {
+ await this.table.clickRowAction(
+ datasetName,
+ DatasetListPage.SELECTORS.DELETE_ACTION,
+ );
+ }
+
+ /**
+ * Clicks the export action button for a dataset
+ * @param datasetName - The name of the dataset to export
+ */
+ async clickExportAction(datasetName: string): Promise<void> {
+ await this.table.clickRowAction(
+ datasetName,
+ DatasetListPage.SELECTORS.EXPORT_ACTION,
+ );
+ }
+
+ /**
+ * Clicks the duplicate action button for a dataset (virtual datasets only)
+ * @param datasetName - The name of the dataset to duplicate
+ */
+ async clickDuplicateAction(datasetName: string): Promise<void> {
+ await this.table.clickRowAction(
+ datasetName,
+ DatasetListPage.SELECTORS.DUPLICATE_ACTION,
+ );
+ }
+}
diff --git a/superset-frontend/playwright/pages/ExplorePage.ts
b/superset-frontend/playwright/pages/ExplorePage.ts
new file mode 100644
index 0000000000..5581fdb7a7
--- /dev/null
+++ b/superset-frontend/playwright/pages/ExplorePage.ts
@@ -0,0 +1,88 @@
+/**
+ * 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, Locator } from '@playwright/test';
+import { TIMEOUT } from '../utils/constants';
+
+/**
+ * Explore Page object
+ */
+export class ExplorePage {
+ private readonly page: Page;
+
+ private static readonly SELECTORS = {
+ DATASOURCE_CONTROL: '[data-test="datasource-control"]',
+ VIZ_SWITCHER: '[data-test="fast-viz-switcher"]',
+ } as const;
+
+ constructor(page: Page) {
+ this.page = page;
+ }
+
+ /**
+ * Waits for the Explore page to load.
+ * Validates URL contains /explore/ and datasource control is visible.
+ *
+ * @param options - Optional wait options
+ */
+ async waitForPageLoad(options?: { timeout?: number }): Promise<void> {
+ const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
+
+ await this.page.waitForURL('**/explore/**', { timeout });
+
+ await this.page.waitForSelector(ExplorePage.SELECTORS.DATASOURCE_CONTROL, {
+ state: 'visible',
+ timeout,
+ });
+ }
+
+ /**
+ * Gets the datasource control locator.
+ * Returns a Locator that tests can use with expect() or to read text.
+ *
+ * @returns Locator for the datasource control
+ *
+ * @example
+ * const name = await explorePage.getDatasourceControl().textContent();
+ */
+ getDatasourceControl(): Locator {
+ return this.page.locator(ExplorePage.SELECTORS.DATASOURCE_CONTROL);
+ }
+
+ /**
+ * Gets the currently selected dataset name from the datasource control
+ */
+ async getDatasetName(): Promise<string> {
+ const text = await this.getDatasourceControl().textContent();
+ return text?.trim() || '';
+ }
+
+ /**
+ * Gets the visualization switcher locator.
+ * Returns a Locator that tests can use with expect().toBeVisible(), etc.
+ *
+ * @returns Locator for the viz switcher
+ *
+ * @example
+ * await expect(explorePage.getVizSwitcher()).toBeVisible();
+ */
+ getVizSwitcher(): Locator {
+ return this.page.locator(ExplorePage.SELECTORS.VIZ_SWITCHER);
+ }
+}
diff --git a/superset-frontend/playwright/tests/auth/login.spec.ts
b/superset-frontend/playwright/tests/auth/login.spec.ts
index 713cd9c1a7..38fd48ee95 100644
--- a/superset-frontend/playwright/tests/auth/login.spec.ts
+++ b/superset-frontend/playwright/tests/auth/login.spec.ts
@@ -20,69 +20,74 @@
import { test, expect } from '@playwright/test';
import { AuthPage } from '../../pages/AuthPage';
import { URL } from '../../utils/urls';
+import { TIMEOUT } from '../../utils/constants';
-test.describe('Login view', () => {
- let authPage: AuthPage;
+// Test credentials - can be overridden via environment variables
+const adminUsername = process.env.PLAYWRIGHT_ADMIN_USERNAME || 'admin';
+const adminPassword = process.env.PLAYWRIGHT_ADMIN_PASSWORD || 'general';
- test.beforeEach(async ({ page }: any) => {
- authPage = new AuthPage(page);
- await authPage.goto();
- await authPage.waitForLoginForm();
- });
+/**
+ * Auth/login tests use per-test navigation via beforeEach.
+ * Each test starts fresh on the login page without global authentication.
+ * This follows the Cypress pattern for auth testing - simple and isolated.
+ */
- test('should redirect to login with incorrect username and password', async
({
- page,
- }: any) => {
- // Setup request interception before login attempt
- const loginRequestPromise = authPage.waitForLoginRequest();
+let authPage: AuthPage;
- // Attempt login with incorrect credentials
- await authPage.loginWithCredentials('admin', 'wrongpassword');
+test.beforeEach(async ({ page }) => {
+ // Navigate to login page before each test (ensures clean state)
+ authPage = new AuthPage(page);
+ await authPage.goto();
+ await authPage.waitForLoginForm();
+});
- // Wait for login request and verify response
- const loginResponse = await loginRequestPromise;
- // Failed login returns 401 Unauthorized or 302 redirect to login
- expect([401, 302]).toContain(loginResponse.status());
+test('should redirect to login with incorrect username and password', async ({
+ page,
+}) => {
+ // Setup request interception before login attempt
+ const loginRequestPromise = authPage.waitForLoginRequest();
- // Wait for redirect to complete before checking URL
- await page.waitForURL((url: any) => url.pathname.endsWith('login/'), {
- timeout: 10000,
- });
+ // Attempt login with incorrect credentials (both username and password
invalid)
+ await authPage.loginWithCredentials('wronguser', 'wrongpassword');
- // Verify we stay on login page
- const currentUrl = await authPage.getCurrentUrl();
- expect(currentUrl).toContain(URL.LOGIN);
+ // Wait for login request and verify response
+ const loginResponse = await loginRequestPromise;
+ // Failed login returns 401 Unauthorized or 302 redirect to login
+ expect([401, 302]).toContain(loginResponse.status());
- // Verify error message is shown
- const hasError = await authPage.hasLoginError();
- expect(hasError).toBe(true);
+ // Wait for redirect to complete before checking URL
+ await page.waitForURL(url => url.pathname.endsWith(URL.LOGIN), {
+ timeout: TIMEOUT.PAGE_LOAD,
});
- test('should login with correct username and password', async ({
- page,
- }: any) => {
- // Setup request interception before login attempt
- const loginRequestPromise = authPage.waitForLoginRequest();
-
- // Login with correct credentials
- await authPage.loginWithCredentials('admin', 'general');
-
- // Wait for login request and verify response
- const loginResponse = await loginRequestPromise;
- // Successful login returns 302 redirect
- expect(loginResponse.status()).toBe(302);
-
- // Wait for successful redirect to welcome page
- await page.waitForURL(
- (url: any) => url.pathname.endsWith('superset/welcome/'),
- {
- timeout: 10000,
- },
- );
-
- // Verify specific session cookie exists
- const sessionCookie = await authPage.getSessionCookie();
- expect(sessionCookie).not.toBeNull();
- expect(sessionCookie?.value).toBeTruthy();
+ // Verify we stay on login page
+ const currentUrl = await authPage.getCurrentUrl();
+ expect(currentUrl).toContain(URL.LOGIN);
+
+ // Verify error message is shown
+ const hasError = await authPage.hasLoginError();
+ expect(hasError).toBe(true);
+});
+
+test('should login with correct username and password', async ({ page }) => {
+ // Setup request interception before login attempt
+ const loginRequestPromise = authPage.waitForLoginRequest();
+
+ // Login with correct credentials
+ await authPage.loginWithCredentials(adminUsername, adminPassword);
+
+ // Wait for login request and verify response
+ const loginResponse = await loginRequestPromise;
+ // Successful login returns 302 redirect
+ expect(loginResponse.status()).toBe(302);
+
+ // Wait for successful redirect to welcome page
+ await page.waitForURL(url => url.pathname.endsWith(URL.WELCOME), {
+ timeout: TIMEOUT.PAGE_LOAD,
});
+
+ // Verify specific session cookie exists
+ const sessionCookie = await authPage.getSessionCookie();
+ expect(sessionCookie).not.toBeNull();
+ expect(sessionCookie?.value).toBeTruthy();
});
diff --git a/superset-frontend/playwright/tests/experimental/README.md
b/superset-frontend/playwright/tests/experimental/README.md
index 9647fb2396..a1511695b1 100644
--- a/superset-frontend/playwright/tests/experimental/README.md
+++ b/superset-frontend/playwright/tests/experimental/README.md
@@ -19,52 +19,98 @@ under the License.
# Experimental Playwright Tests
-This directory contains Playwright tests that are still under development or
validation.
-
## Purpose
-Tests in this directory run in "shadow mode" with `continue-on-error: true` in
CI:
-- Failures do NOT block PR merges
-- Allows tests to run in CI to validate stability before promotion
-- Provides visibility into test reliability over time
+This directory contains **experimental** Playwright E2E tests that are being
developed and stabilized before becoming part of the required test suite.
+
+## How Experimental Tests Work
+
+### Running Tests
+
+**By default (CI and local), experimental tests are EXCLUDED:**
+```bash
+npm run playwright:test
+# Only runs stable tests (tests/auth/*)
+```
+
+**To include experimental tests, set the environment variable:**
+```bash
+INCLUDE_EXPERIMENTAL=true npm run playwright:test
+# Runs all tests including experimental/
+```
+
+### CI Behavior
+
+- **Required CI jobs**: Experimental tests are excluded by default
+ - Tests in `experimental/` do NOT block merges
+ - Failures in `experimental/` do NOT fail the build
-## Promoting Tests to Stable
+- **Experimental CI jobs** (optional): Use `TEST_PATH=experimental/`
+ - Set `INCLUDE_EXPERIMENTAL=true` in the job environment to include
experimental tests
+ - These jobs can use `continue-on-error: true` for shadow mode
-Once a test has proven stable (no false positives/negatives over sufficient
time):
+### Configuration
+
+The experimental pattern is configured in `playwright.config.ts`:
+
+```typescript
+testIgnore: process.env.INCLUDE_EXPERIMENTAL
+ ? undefined
+ : '**/experimental/**',
+```
-1. Move the test file out of `experimental/` to the appropriate feature
directory:
+This ensures:
+- Without `INCLUDE_EXPERIMENTAL`: Tests in `experimental/` are ignored
+- With `INCLUDE_EXPERIMENTAL=true`: All tests run, including experimental
+
+## When to Use Experimental
+
+Add tests to `experimental/` when:
+
+1. **Testing new infrastructure** - New page objects, components, or patterns
that need real-world validation
+2. **Flaky tests** - Tests that pass locally but have intermittent CI failures
that need investigation
+3. **New test types** - E2E tests for new features that need to prove
stability before becoming required
+4. **Prototyping** - Experimental approaches that may or may not become
standard patterns
+
+## Moving Tests to Stable
+
+Once an experimental test has proven stable (consistent CI passes over time):
+
+1. **Move the test file** from `experimental/` to the appropriate stable
directory:
```bash
- # From the repository root:
- git mv
superset-frontend/playwright/tests/experimental/dashboard/test.spec.ts \
- superset-frontend/playwright/tests/dashboard/
+ git mv tests/experimental/dataset/my-test.spec.ts
tests/dataset/my-test.spec.ts
+ ```
- # Or from the superset-frontend/ directory:
- git mv playwright/tests/experimental/dashboard/test.spec.ts \
- playwright/tests/dashboard/
+2. **Commit the move** with a clear message:
+ ```bash
+ git commit -m "test(playwright): promote my-test from experimental to
stable"
```
-2. The test will automatically become required for merge
+3. **Test will now be required** - It will run by default and block merges on
failure
-## Test Organization
+## Current Experimental Tests
-Organize tests by feature area:
-- `auth/` - Authentication and authorization tests
-- `dashboard/` - Dashboard functionality tests
-- `explore/` - Chart builder tests
-- `sqllab/` - SQL Lab tests
-- etc.
+### Dataset Tests
-## Running Tests
+- **`dataset/dataset-list.spec.ts`** - Dataset list E2E tests
+ - Status: Infrastructure complete, validating stability
+ - Includes: Delete dataset test with API-based test data
+ - Supporting infrastructure: API helpers, Modal components, page objects
-```bash
-# Run all experimental tests (requires INCLUDE_EXPERIMENTAL env var)
-INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/
+## Infrastructure Location
-# Run specific experimental test
-INCLUDE_EXPERIMENTAL=true npm run playwright:test --
experimental/dashboard/test.spec.ts
+**Important**: Supporting infrastructure (components, page objects, API
helpers) should live in **stable locations**, NOT under `experimental/`:
-# Run in UI mode for debugging
-INCLUDE_EXPERIMENTAL=true npm run playwright:ui -- experimental/
-```
+✅ **Correct locations:**
+- `playwright/components/` - Components used by any tests
+- `playwright/pages/` - Page objects for any features
+- `playwright/helpers/api/` - API helpers for test data setup
+
+❌ **Avoid:**
+- `playwright/tests/experimental/components/` - Makes it hard to share
infrastructure
+
+This keeps infrastructure reusable and avoids duplication when tests graduate
from experimental to stable.
+
+## Questions?
-**Note**: The `INCLUDE_EXPERIMENTAL=true` environment variable is required
because experimental tests are filtered out by default in
`playwright.config.ts`. Without it, Playwright will report "No tests found".
+See [Superset Testing
Documentation](https://superset.apache.org/docs/contributing/development#testing)
or ask in the `#testing` Slack channel.
diff --git
a/superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts
b/superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts
new file mode 100644
index 0000000000..0eb9cc9d88
--- /dev/null
+++
b/superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts
@@ -0,0 +1,254 @@
+/**
+ * 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 } from '@playwright/test';
+import { DatasetListPage } from '../../../pages/DatasetListPage';
+import { ExplorePage } from '../../../pages/ExplorePage';
+import { DeleteConfirmationModal } from
'../../../components/modals/DeleteConfirmationModal';
+import { DuplicateDatasetModal } from
'../../../components/modals/DuplicateDatasetModal';
+import { Toast } from '../../../components/core/Toast';
+import {
+ apiDeleteDataset,
+ apiGetDataset,
+ getDatasetByName,
+ ENDPOINTS,
+} from '../../../helpers/api/dataset';
+
+/**
+ * Test data constants
+ * These reference example datasets loaded via --load-examples in CI.
+ *
+ * DEPENDENCY: Tests assume the example dataset exists and is a virtual
dataset.
+ * If examples aren't loaded or the dataset changes, tests will fail.
+ * This is acceptable for experimental tests; stable tests should use dedicated
+ * seeded test data to decouple from example data changes.
+ */
+const TEST_DATASETS = {
+ EXAMPLE_DATASET: 'members_channels_2',
+} as const;
+
+/**
+ * Dataset List E2E Tests
+ *
+ * Uses flat test() structure per project convention (matches login.spec.ts).
+ * Shared state and hooks are at file scope.
+ */
+
+// File-scope state (reset in beforeEach)
+let datasetListPage: DatasetListPage;
+let explorePage: ExplorePage;
+let testResources: { datasetIds: number[] } = { datasetIds: [] };
+
+test.beforeEach(async ({ page }) => {
+ datasetListPage = new DatasetListPage(page);
+ explorePage = new ExplorePage(page);
+ testResources = { datasetIds: [] }; // Reset for each test
+
+ // Navigate to dataset list page
+ await datasetListPage.goto();
+ await datasetListPage.waitForTableLoad();
+});
+
+test.afterEach(async ({ page }) => {
+ // Cleanup any resources created during the test
+ const promises = [];
+ for (const datasetId of testResources.datasetIds) {
+ promises.push(
+ apiDeleteDataset(page, datasetId, {
+ failOnStatusCode: false,
+ }).catch(error => {
+ // Log cleanup failures to avoid silent resource leaks
+ console.warn(
+ `[Cleanup] Failed to delete dataset ${datasetId}:`,
+ String(error),
+ );
+ }),
+ );
+ }
+ await Promise.all(promises);
+});
+
+test('should navigate to Explore when dataset name is clicked', async ({
+ page,
+}) => {
+ // Use existing example dataset (hermetic - loaded in CI via --load-examples)
+ const datasetName = TEST_DATASETS.EXAMPLE_DATASET;
+ const dataset = await getDatasetByName(page, datasetName);
+ expect(dataset).not.toBeNull();
+
+ // Verify dataset is visible in list (uses page object + Playwright
auto-wait)
+ await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
+
+ // Click on dataset name to navigate to Explore
+ await datasetListPage.clickDatasetName(datasetName);
+
+ // Wait for Explore page to load (validates URL + datasource control)
+ await explorePage.waitForPageLoad();
+
+ // Verify correct dataset is loaded in datasource control
+ const loadedDatasetName = await explorePage.getDatasetName();
+ expect(loadedDatasetName).toContain(datasetName);
+
+ // Verify visualization switcher shows default viz type (indicates full page
load)
+ await expect(explorePage.getVizSwitcher()).toBeVisible();
+ await expect(explorePage.getVizSwitcher()).toContainText('Table');
+});
+
+test('should delete a dataset with confirmation', async ({ page }) => {
+ // Get example dataset to duplicate
+ const originalName = TEST_DATASETS.EXAMPLE_DATASET;
+ const originalDataset = await getDatasetByName(page, originalName);
+ expect(originalDataset).not.toBeNull();
+
+ // Create throwaway copy for deletion (hermetic - uses UI duplication)
+ const datasetName = `test_delete_${Date.now()}`;
+
+ // Verify original dataset is visible in list
+ await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
+
+ // Set up response intercept to capture duplicate dataset ID
+ const duplicateResponsePromise = page.waitForResponse(
+ response =>
+ response.url().includes(`${ENDPOINTS.DATASET}duplicate`) &&
+ response.status() === 201,
+ );
+
+ // Click duplicate action button
+ await datasetListPage.clickDuplicateAction(originalName);
+
+ // Duplicate modal should appear and be ready for interaction
+ const duplicateModal = new DuplicateDatasetModal(page);
+ await duplicateModal.waitForReady();
+
+ // Fill in new dataset name
+ await duplicateModal.fillDatasetName(datasetName);
+
+ // Click the Duplicate button
+ await duplicateModal.clickDuplicate();
+
+ // Get the duplicate dataset ID from response and track immediately
+ const duplicateResponse = await duplicateResponsePromise;
+ const duplicateData = await duplicateResponse.json();
+ const duplicateId = duplicateData.id;
+
+ // Track duplicate for cleanup immediately (before any operations that could
fail)
+ testResources = { datasetIds: [duplicateId] };
+
+ // Modal should close
+ await duplicateModal.waitForHidden();
+
+ // Refresh page to see new dataset
+ await datasetListPage.goto();
+ await datasetListPage.waitForTableLoad();
+
+ // Verify dataset is visible in list
+ await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
+
+ // Click delete action button
+ await datasetListPage.clickDeleteAction(datasetName);
+
+ // Delete confirmation modal should appear
+ const deleteModal = new DeleteConfirmationModal(page);
+ await deleteModal.waitForVisible();
+
+ // Type "DELETE" to confirm
+ await deleteModal.fillConfirmationInput('DELETE');
+
+ // Click the Delete button
+ await deleteModal.clickDelete();
+
+ // Modal should close
+ await deleteModal.waitForHidden();
+
+ // Verify success toast appears with correct message
+ const toast = new Toast(page);
+ const successToast = toast.getSuccess();
+ await expect(successToast).toBeVisible();
+ await expect(toast.getMessage()).toContainText('Deleted');
+
+ // Verify dataset is removed from list
+ await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
+});
+
+test('should duplicate a dataset with new name', async ({ page }) => {
+ // Use virtual example dataset
+ const originalName = TEST_DATASETS.EXAMPLE_DATASET;
+ const duplicateName = `duplicate_${originalName}_${Date.now()}`;
+
+ // Get the dataset by name (ID varies by environment)
+ const original = await getDatasetByName(page, originalName);
+ expect(original).not.toBeNull();
+ expect(original!.id).toBeGreaterThan(0);
+
+ // Verify original dataset is visible in list
+ await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
+
+ // Set up response intercept to capture duplicate dataset ID
+ const duplicateResponsePromise = page.waitForResponse(
+ response =>
+ response.url().includes(`${ENDPOINTS.DATASET}duplicate`) &&
+ response.status() === 201,
+ );
+
+ // Click duplicate action button
+ await datasetListPage.clickDuplicateAction(originalName);
+
+ // Duplicate modal should appear and be ready for interaction
+ const duplicateModal = new DuplicateDatasetModal(page);
+ await duplicateModal.waitForReady();
+
+ // Fill in new dataset name
+ await duplicateModal.fillDatasetName(duplicateName);
+
+ // Click the Duplicate button
+ await duplicateModal.clickDuplicate();
+
+ // Get the duplicate dataset ID from response
+ const duplicateResponse = await duplicateResponsePromise;
+ const duplicateData = await duplicateResponse.json();
+ const duplicateId = duplicateData.id;
+
+ // Track duplicate for cleanup (original is example data, don't delete it)
+ testResources = { datasetIds: [duplicateId] };
+
+ // Modal should close
+ await duplicateModal.waitForHidden();
+
+ // Note: Duplicate action does not show a success toast (only errors)
+ // Verification is done via API and UI list check below
+
+ // Refresh to see the duplicated dataset
+ await datasetListPage.goto();
+ await datasetListPage.waitForTableLoad();
+
+ // Verify both datasets exist in list
+ await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
+ await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
+
+ // API Verification: Compare original and duplicate datasets
+ const duplicateResponseData = await apiGetDataset(page, duplicateId);
+ const duplicateDataFull = await duplicateResponseData.json();
+
+ // Verify key properties were copied correctly (original data already
fetched)
+ expect(duplicateDataFull.result.sql).toBe(original!.sql);
+ expect(duplicateDataFull.result.database.id).toBe(original!.database.id);
+ expect(duplicateDataFull.result.schema).toBe(original!.schema);
+ // Name should be different (the duplicate name)
+ expect(duplicateDataFull.result.table_name).toBe(duplicateName);
+});
diff --git a/superset-frontend/playwright/utils/urls.ts
b/superset-frontend/playwright/utils/constants.ts
similarity index 51%
copy from superset-frontend/playwright/utils/urls.ts
copy to superset-frontend/playwright/utils/constants.ts
index 67b9e466f3..c9199c2f6d 100644
--- a/superset-frontend/playwright/utils/urls.ts
+++ b/superset-frontend/playwright/utils/constants.ts
@@ -17,7 +17,30 @@
* under the License.
*/
-export const URL = {
- LOGIN: 'login/',
- WELCOME: 'superset/welcome/',
+/**
+ * Timeout constants for Playwright tests.
+ * Only define timeouts that differ from Playwright defaults or are
semantically important.
+ *
+ * Default Playwright timeouts (from playwright.config.ts):
+ * - Test timeout: 30000ms (30s)
+ * - Expect timeout: 8000ms (8s)
+ *
+ * Use these constants instead of magic numbers for better maintainability.
+ */
+
+export const TIMEOUT = {
+ /**
+ * Global setup timeout (matches test timeout for cold CI starts)
+ */
+ GLOBAL_SETUP: 30000, // 30s for global setup auth
+
+ /**
+ * Page navigation and load timeouts
+ */
+ PAGE_LOAD: 10000, // 10s for page transitions (login → welcome, dataset →
explore)
+
+ /**
+ * Form and UI element load timeouts
+ */
+ FORM_LOAD: 5000, // 5s for forms to become visible (login form, modals)
} as const;
diff --git a/superset-frontend/playwright/utils/urls.ts
b/superset-frontend/playwright/utils/urls.ts
index 67b9e466f3..f3578de6fe 100644
--- a/superset-frontend/playwright/utils/urls.ts
+++ b/superset-frontend/playwright/utils/urls.ts
@@ -17,7 +17,18 @@
* under the License.
*/
+/**
+ * URL constants for Playwright navigation
+ *
+ * These are relative paths (no leading '/') that rely on baseURL ending with
'/'.
+ * playwright.config.ts normalizes baseURL to always end with '/' to ensure
+ * correct URL resolution with APP_PREFIX (e.g., /app/prefix/).
+ *
+ * Example: baseURL='http://localhost:8088/app/prefix/' + 'tablemodelview/list'
+ * = 'http://localhost:8088/app/prefix/tablemodelview/list'
+ */
export const URL = {
+ DATASET_LIST: 'tablemodelview/list',
LOGIN: 'login/',
WELCOME: 'superset/welcome/',
} as const;