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;

Reply via email to