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 5040db859c8 test(playwright): additional dataset list playwright tests 
(#36684)
5040db859c8 is described below

commit 5040db859c8e2342e52e583ddea31e4d581c7cfd
Author: Joe Li <[email protected]>
AuthorDate: Thu Feb 5 16:42:07 2026 -0800

    test(playwright): additional dataset list playwright tests (#36684)
    
    Co-authored-by: Claude Opus 4.5 <[email protected]>
    Co-authored-by: Copilot <[email protected]>
---
 superset-frontend/package-lock.json                |  26 +
 superset-frontend/package.json                     |   2 +
 superset-frontend/playwright.config.ts             |  24 +-
 .../playwright/components/ListView/BulkSelect.ts   | 116 ++++
 .../components/{modals => ListView}/index.ts       |   5 +-
 .../playwright/components/core/AceEditor.ts        | 207 +++++++
 .../playwright/components/core/Checkbox.ts         |  95 ++++
 .../playwright/components/core/Select.ts           | 187 ++++++
 .../playwright/components/core/Tabs.ts             |  75 +++
 .../playwright/components/core/Textarea.ts         | 109 ++++
 .../playwright/components/core/index.ts            |   5 +
 .../playwright/components/modals/ConfirmDialog.ts  |  75 +++
 .../components/modals/DuplicateDatasetModal.ts     |   5 +-
 .../components/modals/EditDatasetModal.ts          | 189 +++++++
 .../components/modals/ImportDatasetModal.ts        |  73 +++
 .../playwright/components/modals/index.ts          |   1 +
 .../playwright/fixtures/dataset_export.zip         | Bin 0 -> 5261 bytes
 .../playwright/helpers/api/assertions.ts           |  61 ++
 .../playwright/helpers/api/database.ts             |  74 ++-
 .../playwright/helpers/api/dataset.ts              |  69 ++-
 .../playwright/helpers/api/intercepts.ts           | 145 +++++
 .../modals => helpers/fixtures}/index.ts           |   5 +-
 .../playwright/helpers/fixtures/testAssets.ts      |  68 +++
 .../playwright/pages/ChartCreationPage.ts          | 138 +++++
 .../playwright/pages/CreateDatasetPage.ts          | 138 +++++
 .../playwright/pages/DatasetListPage.ts            |  99 +++-
 .../experimental/dataset/create-dataset.spec.ts    | 219 +++++++
 .../experimental/dataset/dataset-list.spec.ts      | 630 ++++++++++++++++++---
 .../experimental/dataset/dataset-test-helpers.ts   |  67 +++
 superset-frontend/playwright/utils/constants.ts    |  10 +
 30 files changed, 2788 insertions(+), 129 deletions(-)

diff --git a/superset-frontend/package-lock.json 
b/superset-frontend/package-lock.json
index 24b62f8ed9c..cb958b218a4 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -206,6 +206,7 @@
         "@types/rison": "0.1.0",
         "@types/sinon": "^17.0.3",
         "@types/tinycolor2": "^1.4.3",
+        "@types/unzipper": "^0.10.11",
         "@typescript-eslint/eslint-plugin": "^7.18.0",
         "@typescript-eslint/parser": "^7.18.0",
         "babel-jest": "^30.0.2",
@@ -279,6 +280,7 @@
         "tscw-config": "^1.1.2",
         "tsx": "^4.21.0",
         "typescript": "5.4.5",
+        "unzipper": "^0.12.3",
         "vm-browserify": "^1.1.2",
         "wait-on": "^9.0.3",
         "webpack": "^5.105.0",
@@ -20401,6 +20403,16 @@
       "integrity": 
"sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
       "license": "MIT"
     },
+    "node_modules/@types/unzipper": {
+      "version": "0.10.11",
+      "resolved": 
"https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.11.tgz";,
+      "integrity": 
"sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/urijs": {
       "version": "1.19.26",
       "resolved": 
"https://registry.npmjs.org/@types/urijs/-/urijs-1.19.26.tgz";,
@@ -57845,6 +57857,20 @@
         "node": ">=8"
       }
     },
+    "node_modules/unzipper": {
+      "version": "0.12.3",
+      "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz";,
+      "integrity": 
"sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "bluebird": "~3.7.2",
+        "duplexer2": "~0.1.4",
+        "fs-extra": "^11.2.0",
+        "graceful-fs": "^4.2.2",
+        "node-int64": "^0.4.0"
+      }
+    },
     "node_modules/upath": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz";,
diff --git a/superset-frontend/package.json b/superset-frontend/package.json
index 633975d6424..ca37743fc38 100644
--- a/superset-frontend/package.json
+++ b/superset-frontend/package.json
@@ -288,6 +288,7 @@
     "@types/rison": "0.1.0",
     "@types/sinon": "^17.0.3",
     "@types/tinycolor2": "^1.4.3",
+    "@types/unzipper": "^0.10.11",
     "@typescript-eslint/eslint-plugin": "^7.18.0",
     "@typescript-eslint/parser": "^7.18.0",
     "babel-jest": "^30.0.2",
@@ -361,6 +362,7 @@
     "tscw-config": "^1.1.2",
     "tsx": "^4.21.0",
     "typescript": "5.4.5",
+    "unzipper": "^0.12.3",
     "vm-browserify": "^1.1.2",
     "wait-on": "^9.0.3",
     "webpack": "^5.105.0",
diff --git a/superset-frontend/playwright.config.ts 
b/superset-frontend/playwright.config.ts
index c4fcf3e96f6..2c001297fe5 100644
--- a/superset-frontend/playwright.config.ts
+++ b/superset-frontend/playwright.config.ts
@@ -74,6 +74,9 @@ export default defineConfig({
 
     viewport: { width: 1280, height: 1024 },
 
+    // Accept downloads without prompts (needed for export tests)
+    acceptDownloads: true,
+
     // Screenshots and videos on failure
     screenshot: 'only-on-failure',
     video: 'retain-on-failure',
@@ -117,10 +120,19 @@ export default defineConfig({
   // Web server setup - disabled in CI (Flask started separately in workflow)
   webServer: process.env.CI
     ? undefined
-    : {
-        command: 'curl -f http://localhost:8088/health',
-        url: 'http://localhost:8088/health',
-        reuseExistingServer: true,
-        timeout: 5000,
-      },
+    : (() => {
+        // Support custom base URL (e.g., http://localhost:9012/app/prefix/)
+        const baseUrl =
+          process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
+        // Extract origin (scheme + host + port) for health check
+        // Health endpoint is always at /health regardless of app prefix
+        const healthUrl = new URL('/health', new URL(baseUrl).origin).href;
+        return {
+          // Quote URL to prevent shell injection via PLAYWRIGHT_BASE_URL
+          command: `curl -f '${healthUrl}'`,
+          url: healthUrl,
+          reuseExistingServer: true,
+          timeout: 5000,
+        };
+      })(),
 });
diff --git a/superset-frontend/playwright/components/ListView/BulkSelect.ts 
b/superset-frontend/playwright/components/ListView/BulkSelect.ts
new file mode 100644
index 00000000000..3e4d2dbf87b
--- /dev/null
+++ b/superset-frontend/playwright/components/ListView/BulkSelect.ts
@@ -0,0 +1,116 @@
+/**
+ * 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';
+import { Button, Checkbox, Table } from '../core';
+
+const BULK_SELECT_SELECTORS = {
+  CONTROLS: '[data-test="bulk-select-controls"]',
+  ACTION: '[data-test="bulk-select-action"]',
+} as const;
+
+/**
+ * BulkSelect component for Superset ListView bulk operations.
+ * Provides a reusable interface for bulk selection and actions across list 
pages.
+ *
+ * @example
+ * const bulkSelect = new BulkSelect(page, table);
+ * await bulkSelect.enable();
+ * await bulkSelect.selectRow('my-dataset');
+ * await bulkSelect.selectRow('another-dataset');
+ * await bulkSelect.clickAction('Delete');
+ */
+export class BulkSelect {
+  private readonly page: Page;
+  private readonly table: Table;
+
+  constructor(page: Page, table: Table) {
+    this.page = page;
+    this.table = table;
+  }
+
+  /**
+   * Gets the "Bulk select" toggle button
+   */
+  getToggleButton(): Button {
+    return new Button(
+      this.page,
+      this.page.getByRole('button', { name: 'Bulk select' }),
+    );
+  }
+
+  /**
+   * Enables bulk selection mode by clicking the toggle button
+   */
+  async enable(): Promise<void> {
+    await this.getToggleButton().click();
+  }
+
+  /**
+   * Gets the checkbox for a row by name
+   * @param rowName - The name/text identifying the row
+   */
+  getRowCheckbox(rowName: string): Checkbox {
+    const row = this.table.getRow(rowName);
+    return new Checkbox(this.page, row.getByRole('checkbox'));
+  }
+
+  /**
+   * Selects a row's checkbox in bulk select mode
+   * @param rowName - The name/text identifying the row to select
+   */
+  async selectRow(rowName: string): Promise<void> {
+    await this.getRowCheckbox(rowName).check();
+  }
+
+  /**
+   * Deselects a row's checkbox in bulk select mode
+   * @param rowName - The name/text identifying the row to deselect
+   */
+  async deselectRow(rowName: string): Promise<void> {
+    await this.getRowCheckbox(rowName).uncheck();
+  }
+
+  /**
+   * Gets the bulk select controls container locator (for assertions)
+   */
+  getControls(): Locator {
+    return this.page.locator(BULK_SELECT_SELECTORS.CONTROLS);
+  }
+
+  /**
+   * Gets a bulk action button by name
+   * @param actionName - The name of the bulk action (e.g., "Export", "Delete")
+   */
+  getActionButton(actionName: string): Button {
+    const controls = this.getControls();
+    return new Button(
+      this.page,
+      controls.locator(BULK_SELECT_SELECTORS.ACTION, { hasText: actionName }),
+    );
+  }
+
+  /**
+   * Clicks a bulk action button by name (e.g., "Export", "Delete")
+   * @param actionName - The name of the bulk action to click
+   */
+  async clickAction(actionName: string): Promise<void> {
+    await this.getActionButton(actionName).click();
+  }
+}
diff --git a/superset-frontend/playwright/components/modals/index.ts 
b/superset-frontend/playwright/components/ListView/index.ts
similarity index 82%
copy from superset-frontend/playwright/components/modals/index.ts
copy to superset-frontend/playwright/components/ListView/index.ts
index 83356921ada..09bd815d4db 100644
--- a/superset-frontend/playwright/components/modals/index.ts
+++ b/superset-frontend/playwright/components/ListView/index.ts
@@ -17,6 +17,5 @@
  * under the License.
  */
 
-// Specific modal implementations
-export { DeleteConfirmationModal } from './DeleteConfirmationModal';
-export { DuplicateDatasetModal } from './DuplicateDatasetModal';
+// ListView-specific Playwright Components for Superset
+export { BulkSelect } from './BulkSelect';
diff --git a/superset-frontend/playwright/components/core/AceEditor.ts 
b/superset-frontend/playwright/components/core/AceEditor.ts
new file mode 100644
index 00000000000..0ffc3f92684
--- /dev/null
+++ b/superset-frontend/playwright/components/core/AceEditor.ts
@@ -0,0 +1,207 @@
+/**
+ * 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';
+
+const ACE_EDITOR_SELECTORS = {
+  TEXT_INPUT: '.ace_text-input',
+  TEXT_LAYER: '.ace_text-layer',
+  CONTENT: '.ace_content',
+  SCROLLER: '.ace_scroller',
+} as const;
+
+/**
+ * AceEditor component for interacting with Ace Editor instances in Playwright.
+ * Uses the ace editor API directly for reliable text manipulation.
+ */
+export class AceEditor {
+  readonly page: Page;
+  private readonly locator: Locator;
+
+  constructor(page: Page, selector: string);
+
+  constructor(page: Page, locator: Locator);
+
+  constructor(page: Page, selectorOrLocator: string | Locator) {
+    this.page = page;
+    if (typeof selectorOrLocator === 'string') {
+      this.locator = page.locator(selectorOrLocator);
+    } else {
+      this.locator = selectorOrLocator;
+    }
+  }
+
+  /**
+   * Gets the editor element locator
+   */
+  get element(): Locator {
+    return this.locator;
+  }
+
+  /**
+   * Waits for the ace editor to be fully loaded and ready for interaction.
+   */
+  async waitForReady(): Promise<void> {
+    // Wait for editor to be attached (outer .ace_editor div may be CSS-hidden)
+    await this.locator.waitFor({ state: 'attached' });
+    await this.locator
+      .locator(ACE_EDITOR_SELECTORS.CONTENT)
+      .waitFor({ state: 'attached' });
+    // Wait for window.ace library to be fully loaded (may load async)
+    await this.page.waitForFunction(
+      () =>
+        typeof (window as unknown as { ace?: { edit?: unknown } }).ace?.edit 
===
+        'function',
+      { timeout: 10000 },
+    );
+  }
+
+  /**
+   * Sets text in the ace editor using the ace API.
+   * Uses element handle to target the specific editor instance (not global ID 
lookup).
+   * @param text - The text to set
+   */
+  async setText(text: string): Promise<void> {
+    await this.waitForReady();
+    const elementHandle = await this.locator.elementHandle();
+    if (!elementHandle) {
+      throw new Error('Could not get element handle for ace editor');
+    }
+    await this.page.evaluate(
+      ({ element, value }) => {
+        const windowWithAce = window as unknown as {
+          ace?: {
+            edit(el: Element): {
+              setValue(v: string, c: number): void;
+              session: { getUndoManager(): { reset(): void } };
+            };
+          };
+        };
+        if (!windowWithAce.ace) {
+          throw new Error(
+            'Ace editor library not loaded. Ensure the page has finished 
loading.',
+          );
+        }
+        // ace.edit() accepts either an element ID string or the DOM element 
itself
+        const editor = windowWithAce.ace.edit(element);
+        editor.setValue(value, 1);
+        editor.session.getUndoManager().reset();
+      },
+      { element: elementHandle, value: text },
+    );
+  }
+
+  /**
+   * Gets the text content from the ace editor.
+   * Uses element handle to target the specific editor instance.
+   * @returns The text content
+   */
+  async getText(): Promise<string> {
+    await this.waitForReady();
+    const elementHandle = await this.locator.elementHandle();
+    if (!elementHandle) {
+      throw new Error('Could not get element handle for ace editor');
+    }
+    return this.page.evaluate(element => {
+      const windowWithAce = window as unknown as {
+        ace?: { edit(el: Element): { getValue(): string } };
+      };
+      if (!windowWithAce.ace) {
+        throw new Error(
+          'Ace editor library not loaded. Ensure the page has finished 
loading.',
+        );
+      }
+      return windowWithAce.ace.edit(element).getValue();
+    }, elementHandle);
+  }
+
+  /**
+   * Clears the text in the ace editor.
+   */
+  async clear(): Promise<void> {
+    await this.setText('');
+  }
+
+  /**
+   * Appends text to the existing content in the ace editor.
+   * Uses element handle to target the specific editor instance.
+   * @param text - The text to append
+   */
+  async appendText(text: string): Promise<void> {
+    await this.waitForReady();
+    const elementHandle = await this.locator.elementHandle();
+    if (!elementHandle) {
+      throw new Error('Could not get element handle for ace editor');
+    }
+    await this.page.evaluate(
+      ({ element, value }) => {
+        const windowWithAce = window as unknown as {
+          ace?: {
+            edit(el: Element): {
+              getValue(): string;
+              setValue(v: string, c: number): void;
+            };
+          };
+        };
+        if (!windowWithAce.ace) {
+          throw new Error(
+            'Ace editor library not loaded. Ensure the page has finished 
loading.',
+          );
+        }
+        const editor = windowWithAce.ace.edit(element);
+        const currentText = editor.getValue();
+        // Only add newline if there's existing text that doesn't already end 
with one
+        const needsNewline = currentText && !currentText.endsWith('\n');
+        const newText = currentText + (needsNewline ? '\n' : '') + value;
+        editor.setValue(newText, 1);
+      },
+      { element: elementHandle, value: text },
+    );
+  }
+
+  /**
+   * Focuses the ace editor.
+   * Uses element handle to target the specific editor instance.
+   */
+  async focus(): Promise<void> {
+    await this.waitForReady();
+    const elementHandle = await this.locator.elementHandle();
+    if (!elementHandle) {
+      throw new Error('Could not get element handle for ace editor');
+    }
+    await this.page.evaluate(element => {
+      const windowWithAce = window as unknown as {
+        ace?: { edit(el: Element): { focus(): void } };
+      };
+      if (!windowWithAce.ace) {
+        throw new Error(
+          'Ace editor library not loaded. Ensure the page has finished 
loading.',
+        );
+      }
+      windowWithAce.ace.edit(element).focus();
+    }, elementHandle);
+  }
+
+  /**
+   * Checks if the editor is visible.
+   */
+  async isVisible(): Promise<boolean> {
+    return this.locator.isVisible();
+  }
+}
diff --git a/superset-frontend/playwright/components/core/Checkbox.ts 
b/superset-frontend/playwright/components/core/Checkbox.ts
new file mode 100644
index 00000000000..d8527759baf
--- /dev/null
+++ b/superset-frontend/playwright/components/core/Checkbox.ts
@@ -0,0 +1,95 @@
+/**
+ * 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';
+
+/**
+ * Core Checkbox component used in Playwright tests to interact with checkbox
+ * elements in the Superset UI.
+ *
+ * This class wraps a Playwright {@link Locator} pointing to a checkbox input
+ * and provides convenience methods for common interactions such as checking,
+ * unchecking, toggling, and asserting checkbox state and visibility.
+ *
+ * @example
+ * const checkbox = new Checkbox(page, page.locator('input[type="checkbox"]'));
+ * await checkbox.check();
+ * await expect(await checkbox.isChecked()).toBe(true);
+ *
+ * @param page - The Playwright {@link Page} instance associated with the test.
+ * @param locator - The Playwright {@link Locator} targeting the checkbox 
element.
+ */
+export class Checkbox {
+  readonly page: Page;
+  private readonly locator: Locator;
+
+  constructor(page: Page, locator: Locator) {
+    this.page = page;
+    this.locator = locator;
+  }
+
+  /**
+   * Gets the checkbox element locator
+   */
+  get element(): Locator {
+    return this.locator;
+  }
+
+  /**
+   * Checks the checkbox (ensures it's checked)
+   */
+  async check(): Promise<void> {
+    await this.locator.check();
+  }
+
+  /**
+   * Unchecks the checkbox (ensures it's unchecked)
+   */
+  async uncheck(): Promise<void> {
+    await this.locator.uncheck();
+  }
+
+  /**
+   * Toggles the checkbox state
+   */
+  async toggle(): Promise<void> {
+    await this.locator.click();
+  }
+
+  /**
+   * Checks if the checkbox is checked
+   */
+  async isChecked(): Promise<boolean> {
+    return this.locator.isChecked();
+  }
+
+  /**
+   * Checks if the checkbox is visible
+   */
+  async isVisible(): Promise<boolean> {
+    return this.locator.isVisible();
+  }
+
+  /**
+   * Checks if the checkbox is enabled
+   */
+  async isEnabled(): Promise<boolean> {
+    return this.locator.isEnabled();
+  }
+}
diff --git a/superset-frontend/playwright/components/core/Select.ts 
b/superset-frontend/playwright/components/core/Select.ts
new file mode 100644
index 00000000000..1fb9191bcf5
--- /dev/null
+++ b/superset-frontend/playwright/components/core/Select.ts
@@ -0,0 +1,187 @@
+/**
+ * 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';
+
+/**
+ * Ant Design Select component selectors
+ */
+const SELECT_SELECTORS = {
+  DROPDOWN: '.ant-select-dropdown',
+  OPTION: '.ant-select-item-option',
+  SEARCH_INPUT: '.ant-select-selection-search-input',
+  CLEAR: '.ant-select-clear',
+} as const;
+
+/**
+ * Select component for Ant Design Select/Combobox interactions.
+ */
+export class Select {
+  readonly page: Page;
+  private readonly locator: Locator;
+
+  constructor(page: Page, selector: string);
+  constructor(page: Page, locator: Locator);
+  constructor(page: Page, selectorOrLocator: string | Locator) {
+    this.page = page;
+    if (typeof selectorOrLocator === 'string') {
+      this.locator = page.locator(selectorOrLocator);
+    } else {
+      this.locator = selectorOrLocator;
+    }
+  }
+
+  /**
+   * Creates a Select from a combobox role with the given accessible name
+   * @param page - The Playwright page
+   * @param name - The accessible name (aria-label or placeholder text)
+   */
+  static fromRole(page: Page, name: string): Select {
+    const locator = page.getByRole('combobox', { name });
+    return new Select(page, locator);
+  }
+
+  /**
+   * Gets the select element locator
+   */
+  get element(): Locator {
+    return this.locator;
+  }
+
+  /**
+   * Opens the dropdown, types to filter, and selects an option.
+   * Handles cases where the option may not be initially visible in the 
dropdown.
+   * Waits for dropdown to close after selection to avoid stale dropdowns.
+   * @param optionText - The text of the option to select
+   */
+  async selectOption(optionText: string): Promise<void> {
+    await this.open();
+    await this.type(optionText);
+    await this.clickOption(optionText);
+    // Wait for dropdown to close to avoid multiple visible dropdowns
+    await this.waitForDropdownClose();
+  }
+
+  /**
+   * Waits for dropdown to close after selection
+   * This prevents strict mode violations when multiple selects are used 
sequentially
+   */
+  private async waitForDropdownClose(): Promise<void> {
+    // Wait for dropdown to actually close (become hidden)
+    await this.page
+      .locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
+      .last()
+      .waitFor({ state: 'hidden', timeout: 5000 })
+      .catch(error => {
+        // Only ignore TimeoutError (dropdown may already be closed); re-throw 
others
+        if (!(error instanceof Error) || error.name !== 'TimeoutError') {
+          throw error;
+        }
+      });
+  }
+
+  /**
+   * Opens the dropdown
+   */
+  async open(): Promise<void> {
+    await this.locator.click();
+  }
+
+  /**
+   * Clicks an option in an already-open dropdown by its text content.
+   * Uses selector-based approach matching Cypress patterns.
+   * Handles multiple dropdowns by targeting only visible, non-hidden ones.
+   * @param optionText - The text of the option to click (partial match for 
filtered results)
+   */
+  async clickOption(optionText: string): Promise<void> {
+    // Target visible dropdown (excludes hidden ones via 
:not(.ant-select-dropdown-hidden))
+    // Use .last() in case multiple dropdowns exist - the most recent one is 
what we want
+    const dropdown = this.page
+      .locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
+      .last();
+    await dropdown.waitFor({ state: 'visible' });
+
+    // Find option by text content - use partial match since filtered results 
may have prefixes
+    // (e.g., searching for 'main' shows 'examples.main', 'system.main')
+    // First try exact match, fall back to partial match
+    const exactOption = dropdown
+      .locator(SELECT_SELECTORS.OPTION)
+      .getByText(optionText, { exact: true });
+
+    if ((await exactOption.count()) > 0) {
+      await exactOption.click();
+    } else {
+      // Fall back to first option containing the text
+      const partialOption = dropdown
+        .locator(SELECT_SELECTORS.OPTION)
+        .filter({ hasText: optionText })
+        .first();
+      await partialOption.click();
+    }
+  }
+
+  /**
+   * Closes the dropdown by pressing Escape
+   */
+  async close(): Promise<void> {
+    await this.page.keyboard.press('Escape');
+  }
+
+  /**
+   * Types into the select to filter options (assumes dropdown is open)
+   * @param text - The text to type
+   */
+  async type(text: string): Promise<void> {
+    // Find the actual search input inside the select component
+    const searchInput = this.locator.locator(SELECT_SELECTORS.SEARCH_INPUT);
+    try {
+      // Wait for search input in case dropdown is still rendering
+      await searchInput.first().waitFor({ state: 'attached', timeout: 1000 });
+      await searchInput.first().fill(text);
+    } catch (error) {
+      // Only handle TimeoutError (search input not found); re-throw other 
errors
+      if (!(error instanceof Error) || error.name !== 'TimeoutError') {
+        throw error;
+      }
+      // Fallback: locator might be the input itself (e.g., from 
getByRole('combobox'))
+      await this.locator.fill(text);
+    }
+  }
+
+  /**
+   * Clears the current selection
+   */
+  async clear(): Promise<void> {
+    await this.locator.clear();
+  }
+
+  /**
+   * Checks if the select is visible
+   */
+  async isVisible(): Promise<boolean> {
+    return this.locator.isVisible();
+  }
+
+  /**
+   * Checks if the select is enabled
+   */
+  async isEnabled(): Promise<boolean> {
+    return this.locator.isEnabled();
+  }
+}
diff --git a/superset-frontend/playwright/components/core/Tabs.ts 
b/superset-frontend/playwright/components/core/Tabs.ts
new file mode 100644
index 00000000000..cc4b7f50053
--- /dev/null
+++ b/superset-frontend/playwright/components/core/Tabs.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 { Locator, Page } from '@playwright/test';
+
+/**
+ * Tabs component for Ant Design tab navigation.
+ */
+export class Tabs {
+  readonly page: Page;
+  private readonly locator: Locator;
+
+  constructor(page: Page, locator?: Locator) {
+    this.page = page;
+    // Default to the tablist role if no specific locator provided
+    this.locator = locator ?? page.getByRole('tablist');
+  }
+
+  /**
+   * Gets the tablist element locator
+   */
+  get element(): Locator {
+    return this.locator;
+  }
+
+  /**
+   * Gets a tab by name, scoped to this tablist's container
+   * @param tabName - The name/label of the tab
+   */
+  getTab(tabName: string): Locator {
+    return this.locator.getByRole('tab', { name: tabName });
+  }
+
+  /**
+   * Clicks a tab by name
+   * @param tabName - The name/label of the tab to click
+   */
+  async clickTab(tabName: string): Promise<void> {
+    await this.getTab(tabName).click();
+  }
+
+  /**
+   * Gets the tab panel content for a given tab
+   * @param tabName - The name/label of the tab
+   */
+  getTabPanel(tabName: string): Locator {
+    return this.page.getByRole('tabpanel', { name: tabName });
+  }
+
+  /**
+   * Checks if a tab is selected
+   * @param tabName - The name/label of the tab
+   */
+  async isSelected(tabName: string): Promise<boolean> {
+    const tab = this.getTab(tabName);
+    const ariaSelected = await tab.getAttribute('aria-selected');
+    return ariaSelected === 'true';
+  }
+}
diff --git a/superset-frontend/playwright/components/core/Textarea.ts 
b/superset-frontend/playwright/components/core/Textarea.ts
new file mode 100644
index 00000000000..5fae997f3ee
--- /dev/null
+++ b/superset-frontend/playwright/components/core/Textarea.ts
@@ -0,0 +1,109 @@
+/**
+ * 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';
+
+/**
+ * Playwright helper for interacting with HTML {@link HTMLTextAreaElement | 
`<textarea>`} elements.
+ *
+ * This component wraps a Playwright {@link Locator} and provides convenience 
methods for
+ * filling, clearing, and reading the value of a textarea without having to 
work with
+ * locators directly.
+ *
+ * Typical usage:
+ * ```ts
+ * const textarea = new Textarea(page, 'textarea[name="description"]');
+ * await textarea.fill('Some multi-line text');
+ * const value = await textarea.getValue();
+ * ```
+ *
+ * You can also construct an instance from the `name` attribute:
+ * ```ts
+ * const textarea = Textarea.fromName(page, 'description');
+ * await textarea.clear();
+ * ```
+ */
+export class Textarea {
+  readonly page: Page;
+  private readonly locator: Locator;
+
+  constructor(page: Page, selector: string);
+  constructor(page: Page, locator: Locator);
+  constructor(page: Page, selectorOrLocator: string | Locator) {
+    this.page = page;
+    if (typeof selectorOrLocator === 'string') {
+      this.locator = page.locator(selectorOrLocator);
+    } else {
+      this.locator = selectorOrLocator;
+    }
+  }
+
+  /**
+   * Creates a Textarea from a name attribute
+   * @param page - The Playwright page
+   * @param name - The name attribute value
+   */
+  static fromName(page: Page, name: string): Textarea {
+    const locator = page.locator(`textarea[name="${name}"]`);
+    return new Textarea(page, locator);
+  }
+
+  /**
+   * Gets the textarea element locator
+   */
+  get element(): Locator {
+    return this.locator;
+  }
+
+  /**
+   * Fills the textarea with text (clears existing content)
+   * @param text - The text to fill
+   */
+  async fill(text: string): Promise<void> {
+    await this.locator.fill(text);
+  }
+
+  /**
+   * Clears the textarea content
+   */
+  async clear(): Promise<void> {
+    await this.locator.clear();
+  }
+
+  /**
+   * Gets the current value of the textarea
+   */
+  async getValue(): Promise<string> {
+    return this.locator.inputValue();
+  }
+
+  /**
+   * Checks if the textarea is visible
+   */
+  async isVisible(): Promise<boolean> {
+    return this.locator.isVisible();
+  }
+
+  /**
+   * Checks if the textarea is enabled
+   */
+  async isEnabled(): Promise<boolean> {
+    return this.locator.isEnabled();
+  }
+}
diff --git a/superset-frontend/playwright/components/core/index.ts 
b/superset-frontend/playwright/components/core/index.ts
index 8cbac12d54c..53a2ad71d6e 100644
--- a/superset-frontend/playwright/components/core/index.ts
+++ b/superset-frontend/playwright/components/core/index.ts
@@ -18,10 +18,15 @@
  */
 
 // Core Playwright Components for Superset
+export { AceEditor } from './AceEditor';
 export { Button } from './Button';
+export { Checkbox } from './Checkbox';
 export { Form } from './Form';
 export { Input } from './Input';
 export { Menu } from './Menu';
 export { Modal } from './Modal';
+export { Select } from './Select';
 export { Table } from './Table';
+export { Tabs } from './Tabs';
+export { Textarea } from './Textarea';
 export { Toast } from './Toast';
diff --git a/superset-frontend/playwright/components/modals/ConfirmDialog.ts 
b/superset-frontend/playwright/components/modals/ConfirmDialog.ts
new file mode 100644
index 00000000000..2d1c975e170
--- /dev/null
+++ b/superset-frontend/playwright/components/modals/ConfirmDialog.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 { Page, Locator } from '@playwright/test';
+import { Modal } from '../core/Modal';
+
+/**
+ * Confirm Dialog component for Ant Design Modal.confirm dialogs.
+ * These are the "OK" / "Cancel" confirmation dialogs used throughout Superset.
+ * Uses getByRole with name to target specific confirm dialogs when multiple 
are open.
+ */
+export class ConfirmDialog extends Modal {
+  private readonly specificLocator: Locator;
+
+  constructor(page: Page, dialogName = 'Confirm save') {
+    super(page);
+    // Use getByRole with specific name to avoid strict mode violations
+    // when multiple dialogs are open (e.g., Edit Dataset modal + Confirm save 
dialog)
+    this.specificLocator = page.getByRole('dialog', { name: dialogName });
+  }
+
+  /**
+   * Override element getter to use specific locator
+   */
+  override get element(): Locator {
+    return this.specificLocator;
+  }
+
+  /**
+   * Clicks the OK button to confirm.
+   * @param options.timeout - If provided, silently returns if dialog doesn't 
appear
+   *                          within timeout. If not provided, waits 
indefinitely (strict mode).
+   */
+  async clickOk(options?: { timeout?: number }): Promise<void> {
+    try {
+      await this.element.waitFor({
+        state: 'visible',
+        timeout: options?.timeout,
+      });
+      await this.clickFooterButton('OK');
+      await this.waitForHidden();
+    } catch (error) {
+      // Only swallow TimeoutError when timeout was explicitly provided
+      if (options?.timeout !== undefined) {
+        if (error instanceof Error && error.name === 'TimeoutError') {
+          return;
+        }
+      }
+      throw error;
+    }
+  }
+
+  /**
+   * Clicks the Cancel button to dismiss
+   */
+  async clickCancel(): Promise<void> {
+    await this.clickFooterButton('Cancel');
+  }
+}
diff --git 
a/superset-frontend/playwright/components/modals/DuplicateDatasetModal.ts 
b/superset-frontend/playwright/components/modals/DuplicateDatasetModal.ts
index 68a4fdb1326..3ee53d6d5ee 100644
--- a/superset-frontend/playwright/components/modals/DuplicateDatasetModal.ts
+++ b/superset-frontend/playwright/components/modals/DuplicateDatasetModal.ts
@@ -55,7 +55,10 @@ export class DuplicateDatasetModal extends Modal {
     datasetName: string,
     options?: { timeout?: number; force?: boolean },
   ): Promise<void> {
-    await this.nameInput.fill(datasetName, options);
+    const input = this.nameInput.element;
+    // Clear existing text then fill (fill() clears first, but explicit clear 
is more reliable)
+    await input.clear();
+    await input.fill(datasetName, options);
   }
 
   /**
diff --git a/superset-frontend/playwright/components/modals/EditDatasetModal.ts 
b/superset-frontend/playwright/components/modals/EditDatasetModal.ts
new file mode 100644
index 00000000000..bce66be9625
--- /dev/null
+++ b/superset-frontend/playwright/components/modals/EditDatasetModal.ts
@@ -0,0 +1,189 @@
+/**
+ * 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';
+import { Input, Modal, Tabs, AceEditor } from '../core';
+
+/**
+ * Edit Dataset Modal component (DatasourceModal).
+ * Used for editing dataset properties like description, metrics, columns, etc.
+ * Uses specific dialog name to avoid strict mode violations when multiple 
dialogs are open.
+ */
+export class EditDatasetModal extends Modal {
+  private static readonly SELECTORS = {
+    NAME_INPUT: '[data-test="inline-name"]',
+    LOCK_ICON: '[data-test="lock"]',
+    UNLOCK_ICON: '[data-test="unlock"]',
+  };
+
+  private readonly tabs: Tabs;
+  private readonly specificLocator: Locator;
+
+  constructor(page: Page) {
+    super(page);
+    // Use getByRole with specific name to target Edit Dataset dialog
+    // The dialog has aria-labelledby that resolves to "edit Edit Dataset"
+    this.specificLocator = page.getByRole('dialog', { name: /edit.*dataset/i 
});
+    // Scope tabs to modal's tablist to avoid matching tablists elsewhere on 
page
+    this.tabs = new Tabs(page, this.specificLocator.getByRole('tablist'));
+  }
+
+  /**
+   * Override element getter to use specific locator
+   */
+  override get element(): Locator {
+    return this.specificLocator;
+  }
+
+  /**
+   * Click the Save button to save changes
+   */
+  async clickSave(): Promise<void> {
+    await this.clickFooterButton('Save');
+  }
+
+  /**
+   * Click the Cancel button to discard changes
+   */
+  async clickCancel(): Promise<void> {
+    await this.clickFooterButton('Cancel');
+  }
+
+  /**
+   * Click the lock icon to enable edit mode
+   * The modal starts in read-only mode and requires clicking the lock to edit
+   */
+  async enableEditMode(): Promise<void> {
+    const lockButton = this.body.locator(EditDatasetModal.SELECTORS.LOCK_ICON);
+    await lockButton.click();
+  }
+
+  /**
+   * Gets the dataset name input component
+   */
+  private get nameInput(): Input {
+    return new Input(
+      this.page,
+      this.body.locator(EditDatasetModal.SELECTORS.NAME_INPUT),
+    );
+  }
+
+  /**
+   * Fill in the dataset name field
+   * Note: Call enableEditMode() first if the modal is in read-only mode
+   * @param name - The new dataset name
+   */
+  async fillName(name: string): Promise<void> {
+    await this.nameInput.fill(name);
+  }
+
+  /**
+   * Navigate to a specific tab in the modal
+   * @param tabName - The name of the tab (e.g., 'Source', 'Metrics', 
'Columns')
+   */
+  async clickTab(tabName: string): Promise<void> {
+    await this.tabs.clickTab(tabName);
+  }
+
+  /**
+   * Navigate to the Settings tab
+   */
+  async clickSettingsTab(): Promise<void> {
+    await this.tabs.clickTab('Settings');
+  }
+
+  /**
+   * Navigate to the Columns tab.
+   * Uses regex to avoid matching "Calculated columns" tab, scoped to modal.
+   */
+  async clickColumnsTab(): Promise<void> {
+    // Use regex starting with "Columns" to avoid matching "Calculated columns"
+    // Scope to modal element to avoid matching tabs elsewhere on page
+    await this.element.getByRole('tab', { name: /^Columns/ }).click();
+  }
+
+  /**
+   * Gets the description Ace Editor component (Settings tab).
+   * The Description button and ace-editor are in the same form item.
+   */
+  private get descriptionEditor(): AceEditor {
+    // Use tabpanel role with name "Settings" for more reliable lookup
+    const settingsPanel = this.element.getByRole('tabpanel', {
+      name: 'Settings',
+    });
+    // Find the form item that contains the Description button
+    const descriptionFormItem = settingsPanel
+      .locator('.ant-form-item')
+      .filter({
+        has: this.page.getByRole('button', {
+          name: 'Description',
+          exact: true,
+        }),
+      })
+      .first();
+    // The ace-editor has class .ace_editor within the form item
+    const editorElement = descriptionFormItem.locator('.ace_editor');
+    return new AceEditor(this.page, editorElement);
+  }
+
+  /**
+   * Fill the dataset description field (Settings tab).
+   * @param description - The description text to set
+   */
+  async fillDescription(description: string): Promise<void> {
+    await this.descriptionEditor.setText(description);
+  }
+
+  /**
+   * Expand a column row by column name.
+   * Uses exact cell match to avoid false positives with short names like "ds".
+   * @param columnName - The name of the column to expand
+   * @returns The row locator for scoped selector access
+   */
+  async expandColumn(columnName: string): Promise<Locator> {
+    // Find cell with exact column name text, then derive row from that cell
+    const cell = this.body.getByRole('cell', { name: columnName, exact: true 
});
+    const row = cell.locator('xpath=ancestor::tr[1]');
+    await row.getByRole('button', { name: /expand row/i }).click();
+    return row;
+  }
+
+  /**
+   * Fill column datetime format for a given column.
+   * Expands the column row and fills the date format input.
+   * Note: Expanded content appears in a sibling row, so we scope to modal 
body.
+   * @param columnName - The name of the column to edit
+   * @param format - The python date format string (e.g., '%Y-%m-%d')
+   */
+  async fillColumnDateFormat(
+    columnName: string,
+    format: string,
+  ): Promise<void> {
+    await this.expandColumn(columnName);
+    // Expanded content appears in a sibling row, not nested inside the 
original row.
+    // Use modal body scope with placeholder selector to find the datetime 
format input.
+    const dateFormatInput = new Input(
+      this.page,
+      this.body.getByPlaceholder('%Y-%m-%d'),
+    );
+    await dateFormatInput.element.waitFor({ state: 'visible' });
+    await dateFormatInput.clear();
+    await dateFormatInput.fill(format);
+  }
+}
diff --git 
a/superset-frontend/playwright/components/modals/ImportDatasetModal.ts 
b/superset-frontend/playwright/components/modals/ImportDatasetModal.ts
new file mode 100644
index 00000000000..1399cc53547
--- /dev/null
+++ b/superset-frontend/playwright/components/modals/ImportDatasetModal.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';
+
+/**
+ * Import dataset modal for uploading dataset export files.
+ * Handles file upload, overwrite confirmation, and import submission.
+ */
+export class ImportDatasetModal extends Modal {
+  private static readonly SELECTORS = {
+    FILE_INPUT: '[data-test="model-file-input"]',
+    OVERWRITE_INPUT: '[data-test="overwrite-modal-input"]',
+  };
+
+  /**
+   * Upload a file to the import modal
+   * @param filePath - Absolute path to the file to upload
+   */
+  async uploadFile(filePath: string): Promise<void> {
+    await this.page
+      .locator(ImportDatasetModal.SELECTORS.FILE_INPUT)
+      .setInputFiles(filePath);
+  }
+
+  /**
+   * Fill the overwrite confirmation input (only needed if dataset exists)
+   */
+  async fillOverwriteConfirmation(): Promise<void> {
+    const input = new Input(
+      this.page,
+      this.body.locator(ImportDatasetModal.SELECTORS.OVERWRITE_INPUT),
+    );
+    await input.fill('OVERWRITE');
+  }
+
+  /**
+   * Get the overwrite confirmation input locator
+   */
+  getOverwriteInput() {
+    return this.body.locator(ImportDatasetModal.SELECTORS.OVERWRITE_INPUT);
+  }
+
+  /**
+   * Check if overwrite confirmation is visible
+   */
+  async isOverwriteVisible(): Promise<boolean> {
+    return this.getOverwriteInput().isVisible();
+  }
+
+  /**
+   * Click the Import button in the footer
+   */
+  async clickImport(): Promise<void> {
+    await this.clickFooterButton('Import');
+  }
+}
diff --git a/superset-frontend/playwright/components/modals/index.ts 
b/superset-frontend/playwright/components/modals/index.ts
index 83356921ada..a8f8f1576f3 100644
--- a/superset-frontend/playwright/components/modals/index.ts
+++ b/superset-frontend/playwright/components/modals/index.ts
@@ -20,3 +20,4 @@
 // Specific modal implementations
 export { DeleteConfirmationModal } from './DeleteConfirmationModal';
 export { DuplicateDatasetModal } from './DuplicateDatasetModal';
+export { ImportDatasetModal } from './ImportDatasetModal';
diff --git a/superset-frontend/playwright/fixtures/dataset_export.zip 
b/superset-frontend/playwright/fixtures/dataset_export.zip
new file mode 100644
index 00000000000..5acf5396269
Binary files /dev/null and 
b/superset-frontend/playwright/fixtures/dataset_export.zip differ
diff --git a/superset-frontend/playwright/helpers/api/assertions.ts 
b/superset-frontend/playwright/helpers/api/assertions.ts
new file mode 100644
index 00000000000..b1fe1c3cc51
--- /dev/null
+++ b/superset-frontend/playwright/helpers/api/assertions.ts
@@ -0,0 +1,61 @@
+/**
+ * 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 type { Response, APIResponse } from '@playwright/test';
+import { expect } from '@playwright/test';
+
+/**
+ * Common interface for response types with status() method.
+ * Supports both Response (network interception) and APIResponse (page.request 
API).
+ */
+type ResponseLike = Response | APIResponse;
+
+/**
+ * Verify response has exact status code
+ * @param response - Playwright Response or APIResponse object
+ * @param expected - Expected status code
+ * @returns The response for chaining
+ */
+export function expectStatus<T extends ResponseLike>(
+  response: T,
+  expected: number,
+): T {
+  expect(
+    response.status(),
+    `Expected status ${expected}, got ${response.status()}`,
+  ).toBe(expected);
+  return response;
+}
+
+/**
+ * Verify response status code is one of the expected values
+ * @param response - Playwright Response or APIResponse object
+ * @param expected - Array of acceptable status codes
+ * @returns The response for chaining
+ */
+export function expectStatusOneOf<T extends ResponseLike>(
+  response: T,
+  expected: number[],
+): T {
+  expect(
+    expected,
+    `Expected status to be one of ${expected.join(', ')}, got 
${response.status()}`,
+  ).toContain(response.status());
+  return response;
+}
diff --git a/superset-frontend/playwright/helpers/api/database.ts 
b/superset-frontend/playwright/helpers/api/database.ts
index 31955393ca7..7edea891973 100644
--- a/superset-frontend/playwright/helpers/api/database.ts
+++ b/superset-frontend/playwright/helpers/api/database.ts
@@ -18,12 +18,33 @@
  */
 
 import { Page, APIResponse } from '@playwright/test';
-import { apiPost, apiDelete, ApiRequestOptions } from './requests';
+import rison from 'rison';
+import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
 
 const ENDPOINTS = {
   DATABASE: 'api/v1/database/',
 } as const;
 
+/**
+ * TypeScript interface for database API response
+ */
+export interface DatabaseResult {
+  id: number;
+  database_name: string;
+  /** Optional - list API masks this for security, only detail API returns it 
*/
+  sqlalchemy_uri?: string;
+  backend?: string;
+  engine_information?: {
+    disable_ssh_tunneling?: boolean;
+    supports_dynamic_catalog?: boolean;
+    supports_file_upload?: boolean;
+    supports_oauth2?: boolean;
+  };
+  extra?: string;
+  expose_in_sqllab?: boolean;
+  impersonate_user?: boolean;
+}
+
 /**
  * TypeScript interface for database creation API payload
  * Provides compile-time safety for required fields
@@ -31,6 +52,7 @@ const ENDPOINTS = {
 export interface DatabaseCreatePayload {
   database_name: string;
   engine: string;
+  sqlalchemy_uri?: string;
   configuration_method?: string;
   engine_information?: {
     disable_ssh_tunneling?: boolean;
@@ -77,3 +99,53 @@ export async function apiDeleteDatabase(
 ): Promise<APIResponse> {
   return apiDelete(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
 }
+
+/**
+ * GET request to fetch a database's details
+ * @param page - Playwright page instance (provides authentication context)
+ * @param databaseId - ID of the database to fetch
+ * @returns API response with database details
+ */
+export async function apiGetDatabase(
+  page: Page,
+  databaseId: number,
+  options?: ApiRequestOptions,
+): Promise<APIResponse> {
+  return apiGet(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
+}
+
+/**
+ * Get a database by its name
+ * @param page - Playwright page instance (provides authentication context)
+ * @param databaseName - The database_name to search for
+ * @returns Database object if found, null if not found
+ */
+export async function getDatabaseByName(
+  page: Page,
+  databaseName: string,
+): Promise<DatabaseResult | null> {
+  const filter = {
+    filters: [
+      {
+        col: 'database_name',
+        opr: 'eq',
+        value: databaseName,
+      },
+    ],
+  };
+  const queryParam = rison.encode(filter);
+  const response = await apiGet(page, `${ENDPOINTS.DATABASE}?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 DatabaseResult;
+  }
+
+  return null;
+}
diff --git a/superset-frontend/playwright/helpers/api/dataset.ts 
b/superset-frontend/playwright/helpers/api/dataset.ts
index 4017855f126..2d3175de770 100644
--- a/superset-frontend/playwright/helpers/api/dataset.ts
+++ b/superset-frontend/playwright/helpers/api/dataset.ts
@@ -20,9 +20,13 @@
 import { Page, APIResponse } from '@playwright/test';
 import rison from 'rison';
 import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
+import { getDatabaseByName } from './database';
 
 export const ENDPOINTS = {
   DATASET: 'api/v1/dataset/',
+  DATASET_EXPORT: 'api/v1/dataset/export/',
+  DATASET_DUPLICATE: 'api/v1/dataset/duplicate',
+  DATASET_IMPORT: 'api/v1/dataset/import/',
 } as const;
 
 /**
@@ -37,12 +41,12 @@ export interface DatasetCreatePayload {
 }
 
 /**
- * TypeScript interface for virtual dataset creation API payload
- * Virtual datasets are SQL-based and support the Duplicate action in UI
+ * TypeScript interface for virtual dataset creation API payload.
+ * Virtual datasets are defined by SQL queries rather than physical tables.
  */
 export interface VirtualDatasetCreatePayload {
   database: number;
-  schema: string;
+  schema: string | null;
   table_name: string;
   sql: string;
   owners?: number[];
@@ -55,8 +59,8 @@ export interface VirtualDatasetCreatePayload {
 export interface DatasetResult {
   id: number;
   table_name: string;
-  sql?: string;
-  schema?: string;
+  sql?: string | null;
+  schema?: string | null;
   database: {
     id: number;
     database_name: string;
@@ -79,11 +83,11 @@ export async function apiPostDataset(
 }
 
 /**
- * POST request to create a virtual (SQL-based) dataset
- * Virtual datasets support the Duplicate action in the UI
+ * POST request to create a virtual dataset with SQL.
+ * Use expectStatusOneOf() on the response and handle both result.id and id 
shapes.
  * @param page - Playwright page instance (provides authentication context)
- * @param requestBody - Virtual dataset config (database, schema, table_name, 
sql)
- * @returns API response from dataset creation
+ * @param requestBody - Virtual dataset configuration (database, schema, 
table_name, sql)
+ * @returns API response from virtual dataset creation
  */
 export async function apiPostVirtualDataset(
   page: Page,
@@ -96,16 +100,27 @@ export async function apiPostVirtualDataset(
  * Creates a simple virtual dataset for testing purposes
  * @param page - Playwright page instance
  * @param name - Name for the virtual dataset
- * @param databaseId - ID of the database to use (defaults to 1 for examples 
db)
+ * @param databaseId - ID of the database to use (looks up 'examples' DB if 
not provided)
  * @returns The created dataset ID, or null on failure
  */
 export async function createTestVirtualDataset(
   page: Page,
   name: string,
-  databaseId = 1,
+  databaseId?: number,
 ): Promise<number | null> {
+  // Look up examples database if no ID provided
+  let dbId = databaseId;
+  if (dbId === undefined) {
+    const examplesDb = await getDatabaseByName(page, 'examples');
+    if (!examplesDb?.id) {
+      console.warn('Failed to find examples database');
+      return null;
+    }
+    dbId = examplesDb.id;
+  }
+
   const response = await apiPostVirtualDataset(page, {
-    database: databaseId,
+    database: dbId,
     schema: '',
     table_name: name,
     sql: "SELECT 1 as id, 'test' as name",
@@ -118,7 +133,8 @@ export async function createTestVirtualDataset(
   }
 
   const body = await response.json();
-  return body.id ?? null;
+  // Handle both response shapes: { id } or { result: { id } }
+  return body.result?.id ?? body.id ?? null;
 }
 
 /**
@@ -186,3 +202,30 @@ export async function apiDeleteDataset(
 ): Promise<APIResponse> {
   return apiDelete(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
 }
+
+/**
+ * Duplicate a dataset via the API
+ * @param page - Playwright page instance (provides authentication context)
+ * @param datasetId - ID of the dataset to duplicate
+ * @param newName - Name for the duplicated dataset
+ * @returns Object containing the new dataset's ID (use apiGetDataset for full 
details)
+ */
+export async function duplicateDataset(
+  page: Page,
+  datasetId: number,
+  newName: string,
+): Promise<{ id: number }> {
+  const response = await apiPost(page, `${ENDPOINTS.DATASET}duplicate`, {
+    base_model_id: datasetId,
+    table_name: newName,
+  });
+  const body = await response.json();
+  // Normalize: API may return id at top level or inside result
+  const resolvedId = body.result?.id ?? body.id;
+  if (!resolvedId) {
+    throw new Error(
+      `Duplicate dataset API returned no id. Response: 
${JSON.stringify(body)}`,
+    );
+  }
+  return { id: resolvedId };
+}
diff --git a/superset-frontend/playwright/helpers/api/intercepts.ts 
b/superset-frontend/playwright/helpers/api/intercepts.ts
new file mode 100644
index 00000000000..d813a2a91f6
--- /dev/null
+++ b/superset-frontend/playwright/helpers/api/intercepts.ts
@@ -0,0 +1,145 @@
+/**
+ * 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 type { Page, Response } from '@playwright/test';
+
+/**
+ * HTTP methods enum for consistency
+ */
+export const HTTP_METHODS = {
+  GET: 'GET',
+  POST: 'POST',
+  PUT: 'PUT',
+  DELETE: 'DELETE',
+  PATCH: 'PATCH',
+} as const;
+
+type HttpMethod = (typeof HTTP_METHODS)[keyof typeof HTTP_METHODS];
+
+/**
+ * Options for waitFor* functions
+ */
+interface WaitForResponseOptions {
+  /** Optional timeout in milliseconds */
+  timeout?: number;
+  /** Match against URL pathname suffix instead of full URL includes (default: 
false) */
+  pathMatch?: boolean;
+}
+
+/**
+ * Normalize a path by removing trailing slashes
+ */
+function normalizePath(path: string): string {
+  return path.replace(/\/+$/, '');
+}
+
+/**
+ * Check if a URL matches a pattern
+ * - String + pathMatch: pathname.endsWith(pattern) with trailing slash 
normalization
+ * - String: url.includes(pattern)
+ * - RegExp: pattern.test(url)
+ */
+function matchUrl(
+  url: string,
+  pattern: string | RegExp,
+  pathMatch?: boolean,
+): boolean {
+  if (typeof pattern === 'string') {
+    if (pathMatch) {
+      const pathname = normalizePath(new URL(url).pathname);
+      const normalizedPattern = normalizePath(pattern);
+      return pathname.endsWith(normalizedPattern);
+    }
+    return url.includes(pattern);
+  }
+  return pattern.test(url);
+}
+
+/**
+ * Generic helper to wait for a response matching URL pattern and HTTP method
+ */
+function waitForResponse(
+  page: Page,
+  urlPattern: string | RegExp,
+  method: HttpMethod,
+  options?: WaitForResponseOptions,
+): Promise<Response> {
+  const { pathMatch, ...waitOptions } = options ?? {};
+  return page.waitForResponse(
+    response =>
+      matchUrl(response.url(), urlPattern, pathMatch) &&
+      response.request().method() === method,
+    waitOptions,
+  );
+}
+
+/**
+ * Wait for a GET response matching the URL pattern
+ */
+export function waitForGet(
+  page: Page,
+  urlPattern: string | RegExp,
+  options?: WaitForResponseOptions,
+): Promise<Response> {
+  return waitForResponse(page, urlPattern, HTTP_METHODS.GET, options);
+}
+
+/**
+ * Wait for a POST response matching the URL pattern
+ */
+export function waitForPost(
+  page: Page,
+  urlPattern: string | RegExp,
+  options?: WaitForResponseOptions,
+): Promise<Response> {
+  return waitForResponse(page, urlPattern, HTTP_METHODS.POST, options);
+}
+
+/**
+ * Wait for a PUT response matching the URL pattern
+ */
+export function waitForPut(
+  page: Page,
+  urlPattern: string | RegExp,
+  options?: WaitForResponseOptions,
+): Promise<Response> {
+  return waitForResponse(page, urlPattern, HTTP_METHODS.PUT, options);
+}
+
+/**
+ * Wait for a DELETE response matching the URL pattern
+ */
+export function waitForDelete(
+  page: Page,
+  urlPattern: string | RegExp,
+  options?: WaitForResponseOptions,
+): Promise<Response> {
+  return waitForResponse(page, urlPattern, HTTP_METHODS.DELETE, options);
+}
+
+/**
+ * Wait for a PATCH response matching the URL pattern
+ */
+export function waitForPatch(
+  page: Page,
+  urlPattern: string | RegExp,
+  options?: WaitForResponseOptions,
+): Promise<Response> {
+  return waitForResponse(page, urlPattern, HTTP_METHODS.PATCH, options);
+}
diff --git a/superset-frontend/playwright/components/modals/index.ts 
b/superset-frontend/playwright/helpers/fixtures/index.ts
similarity index 82%
copy from superset-frontend/playwright/components/modals/index.ts
copy to superset-frontend/playwright/helpers/fixtures/index.ts
index 83356921ada..456d193d11f 100644
--- a/superset-frontend/playwright/components/modals/index.ts
+++ b/superset-frontend/playwright/helpers/fixtures/index.ts
@@ -17,6 +17,5 @@
  * under the License.
  */
 
-// Specific modal implementations
-export { DeleteConfirmationModal } from './DeleteConfirmationModal';
-export { DuplicateDatasetModal } from './DuplicateDatasetModal';
+// Base fixture with test asset cleanup
+export { test as testWithAssets, expect, type TestAssets } from './testAssets';
diff --git a/superset-frontend/playwright/helpers/fixtures/testAssets.ts 
b/superset-frontend/playwright/helpers/fixtures/testAssets.ts
new file mode 100644
index 00000000000..16f104f3704
--- /dev/null
+++ b/superset-frontend/playwright/helpers/fixtures/testAssets.ts
@@ -0,0 +1,68 @@
+/**
+ * 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 as base } from '@playwright/test';
+import { apiDeleteDataset } from '../api/dataset';
+import { apiDeleteDatabase } from '../api/database';
+
+/**
+ * Test asset tracker for automatic cleanup after each test.
+ * Inspired by Cypress's cleanDashboards/cleanCharts pattern.
+ */
+export interface TestAssets {
+  trackDataset(id: number): void;
+  trackDatabase(id: number): void;
+}
+
+export const test = base.extend<{ testAssets: TestAssets }>({
+  testAssets: async ({ page }, use) => {
+    // Use Set to de-dupe IDs (same resource may be tracked multiple times)
+    const datasetIds = new Set<number>();
+    const databaseIds = new Set<number>();
+
+    await use({
+      trackDataset: id => datasetIds.add(id),
+      trackDatabase: id => databaseIds.add(id),
+    });
+
+    // Cleanup: Delete datasets FIRST (they reference databases)
+    // Then delete databases. Use failOnStatusCode: false for tolerance.
+    await Promise.all(
+      [...datasetIds].map(id =>
+        apiDeleteDataset(page, id, { failOnStatusCode: false }).catch(error => 
{
+          console.warn(`[testAssets] Failed to cleanup dataset ${id}:`, error);
+        }),
+      ),
+    );
+    await Promise.all(
+      [...databaseIds].map(id =>
+        apiDeleteDatabase(page, id, { failOnStatusCode: false }).catch(
+          error => {
+            console.warn(
+              `[testAssets] Failed to cleanup database ${id}:`,
+              error,
+            );
+          },
+        ),
+      ),
+    );
+  },
+});
+
+export { expect } from '@playwright/test';
diff --git a/superset-frontend/playwright/pages/ChartCreationPage.ts 
b/superset-frontend/playwright/pages/ChartCreationPage.ts
new file mode 100644
index 00000000000..8dc38142938
--- /dev/null
+++ b/superset-frontend/playwright/pages/ChartCreationPage.ts
@@ -0,0 +1,138 @@
+/**
+ * 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 { expect, Locator, Page } from '@playwright/test';
+import { Button, Select } from '../components/core';
+
+/**
+ * Chart Creation Page object for the "Create a new chart" wizard.
+ * This page appears after creating a dataset via the wizard.
+ */
+export class ChartCreationPage {
+  readonly page: Page;
+
+  private static readonly SELECTORS = {
+    VIZ_GALLERY: '.viz-gallery',
+    VIZ_TYPE_ITEM: '[data-test="viz-type-gallery__item"]',
+  } as const;
+
+  constructor(page: Page) {
+    this.page = page;
+  }
+
+  /**
+   * Gets the dataset selector container (includes the displayed selection 
value)
+   */
+  getDatasetSelectContainer(): Locator {
+    return this.page.getByLabel('Dataset', { exact: false }).first();
+  }
+
+  /**
+   * Gets the dataset selector for interactions
+   */
+  getDatasetSelect(): Select {
+    return new Select(
+      this.page,
+      this.page.getByRole('combobox', { name: /dataset/i }),
+    );
+  }
+
+  /**
+   * Gets the visualization gallery container
+   */
+  getVizGallery(): Locator {
+    return this.page.locator(ChartCreationPage.SELECTORS.VIZ_GALLERY);
+  }
+
+  /**
+   * Gets the "Create new chart" button
+   */
+  getCreateChartButton(): Button {
+    return new Button(
+      this.page,
+      this.page.getByRole('button', { name: /create new chart/i }),
+    );
+  }
+
+  /**
+   * Navigate to the chart creation page
+   */
+  async goto(): Promise<void> {
+    await this.page.goto('chart/add');
+  }
+
+  /**
+   * Wait for the page to load (dataset selector visible)
+   */
+  async waitForPageLoad(): Promise<void> {
+    await expect(this.getDatasetSelect().element).toBeVisible({
+      timeout: 10000,
+    });
+  }
+
+  /**
+   * Select a dataset from the dropdown
+   * @param datasetName - The name of the dataset to select
+   */
+  async selectDataset(datasetName: string): Promise<void> {
+    await this.getDatasetSelect().selectOption(datasetName);
+  }
+
+  /**
+   * Select a visualization type from the gallery
+   * @param vizType - The visualization type to select (e.g., 'Table', 'Bar 
Chart')
+   */
+  async selectVizType(vizType: string): Promise<void> {
+    const vizGallery = this.getVizGallery();
+    await expect(vizGallery).toBeVisible();
+
+    // Button names in the gallery are duplicated (e.g., "Table Table", "Bar 
Chart Bar Chart")
+    // because they include both the image alt text and the label text.
+    // Use exact match with the duplicated pattern to avoid matching similar 
names.
+    const vizTypeItem = vizGallery.getByRole('button', {
+      name: `${vizType} ${vizType}`,
+      exact: true,
+    });
+    await vizTypeItem.click();
+  }
+
+  /**
+   * Click the "Create new chart" button to navigate to Explore
+   */
+  async clickCreateNewChart(): Promise<void> {
+    await this.getCreateChartButton().click();
+  }
+
+  /**
+   * Verify the dataset is pre-selected (shown in the selector)
+   * @param datasetName - The expected dataset name
+   */
+  async expectDatasetSelected(datasetName: string): Promise<void> {
+    // For Ant Design selects, the selected value is displayed in a sibling 
element,
+    // not in the combobox input. Check the container for the displayed text.
+    await expect(this.getDatasetSelectContainer()).toContainText(datasetName);
+  }
+
+  /**
+   * Check if the "Create new chart" button is enabled
+   */
+  async isCreateButtonEnabled(): Promise<boolean> {
+    return this.getCreateChartButton().isEnabled();
+  }
+}
diff --git a/superset-frontend/playwright/pages/CreateDatasetPage.ts 
b/superset-frontend/playwright/pages/CreateDatasetPage.ts
new file mode 100644
index 00000000000..ff129c7364c
--- /dev/null
+++ b/superset-frontend/playwright/pages/CreateDatasetPage.ts
@@ -0,0 +1,138 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Page } from '@playwright/test';
+import { Button, Select } from '../components/core';
+
+/**
+ * Create Dataset Page object for the dataset creation wizard.
+ */
+export class CreateDatasetPage {
+  readonly page: Page;
+
+  /**
+   * Data-test selectors for the create dataset form elements.
+   * Using data-test attributes avoids strict mode violations with multiple 
selects.
+   */
+  private static readonly SELECTORS = {
+    DATABASE: '[data-test="select-database"]',
+    SCHEMA: '[data-test="Select schema or type to search schemas"]',
+    TABLE: '[data-test="Select table or type to search tables"]',
+  } as const;
+
+  constructor(page: Page) {
+    this.page = page;
+  }
+
+  /**
+   * Gets the database selector using data-test attribute
+   */
+  getDatabaseSelect(): Select {
+    return new Select(this.page, CreateDatasetPage.SELECTORS.DATABASE);
+  }
+
+  /**
+   * Gets the schema selector using data-test attribute
+   */
+  getSchemaSelect(): Select {
+    return new Select(this.page, CreateDatasetPage.SELECTORS.SCHEMA);
+  }
+
+  /**
+   * Gets the table selector using data-test attribute
+   */
+  getTableSelect(): Select {
+    return new Select(this.page, CreateDatasetPage.SELECTORS.TABLE);
+  }
+
+  /**
+   * Gets the create and explore button
+   */
+  getCreateAndExploreButton(): Button {
+    return new Button(
+      this.page,
+      this.page.getByRole('button', { name: /Create and explore dataset/i }),
+    );
+  }
+
+  /**
+   * Navigate to the create dataset page
+   */
+  async goto(): Promise<void> {
+    await this.page.goto('dataset/add/');
+  }
+
+  /**
+   * Select a database from the dropdown
+   * @param databaseName - The name of the database to select
+   */
+  async selectDatabase(databaseName: string): Promise<void> {
+    await this.getDatabaseSelect().selectOption(databaseName);
+  }
+
+  /**
+   * Select a schema from the dropdown
+   * @param schemaName - The name of the schema to select
+   */
+  async selectSchema(schemaName: string): Promise<void> {
+    await this.getSchemaSelect().selectOption(schemaName);
+  }
+
+  /**
+   * Select a table from the dropdown
+   * @param tableName - The name of the table to select
+   */
+  async selectTable(tableName: string): Promise<void> {
+    await this.getTableSelect().selectOption(tableName);
+  }
+
+  /**
+   * Click the "Create dataset" button (without exploring)
+   * Uses the dropdown menu to select "Create dataset" option
+   */
+  async clickCreateDataset(): Promise<void> {
+    // Find the "Create and explore dataset" button, then its sibling dropdown 
trigger
+    // This avoids ambiguity if other "down" buttons exist on the page
+    const mainButton = this.page.getByRole('button', {
+      name: /Create and explore dataset/i,
+    });
+    // The dropdown trigger is in the same button group, find it relative to 
main button
+    const dropdownTrigger = mainButton
+      .locator('xpath=following-sibling::button')
+      .first();
+    await dropdownTrigger.click();
+
+    // Click "Create dataset" option from the dropdown menu
+    await this.page.getByText('Create dataset', { exact: true }).click();
+  }
+
+  /**
+   * Click the "Create and explore dataset" button
+   */
+  async clickCreateAndExploreDataset(): Promise<void> {
+    await this.getCreateAndExploreButton().click();
+  }
+
+  /**
+   * Wait for the page to load
+   */
+  async waitForPageLoad(): Promise<void> {
+    await this.getDatabaseSelect().element.waitFor({ state: 'visible' });
+  }
+}
diff --git a/superset-frontend/playwright/pages/DatasetListPage.ts 
b/superset-frontend/playwright/pages/DatasetListPage.ts
index a7b6af75a18..77e9a87db25 100644
--- a/superset-frontend/playwright/pages/DatasetListPage.ts
+++ b/superset-frontend/playwright/pages/DatasetListPage.ts
@@ -18,7 +18,8 @@
  */
 
 import { Page, Locator } from '@playwright/test';
-import { Table } from '../components/core';
+import { Button, Table } from '../components/core';
+import { BulkSelect } from '../components/ListView';
 import { URL } from '../utils/urls';
 
 /**
@@ -27,17 +28,26 @@ import { URL } from '../utils/urls';
 export class DatasetListPage {
   private readonly page: Page;
   private readonly table: Table;
+  readonly bulkSelect: BulkSelect;
 
   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;
+
+  /**
+   * Action button names for getByRole('button', { name })
+   */
+  private static readonly ACTION_BUTTONS = {
+    DELETE: 'delete',
+    EDIT: 'edit',
+    EXPORT: 'upload', // Export button uses upload icon
+    DUPLICATE: 'copy',
   } as const;
 
   constructor(page: Page) {
     this.page = page;
     this.table = new Table(page);
+    this.bulkSelect = new BulkSelect(page, this.table);
   }
 
   /**
@@ -85,10 +95,21 @@ export class DatasetListPage {
    * @param datasetName - The name of the dataset to delete
    */
   async clickDeleteAction(datasetName: string): Promise<void> {
-    await this.table.clickRowAction(
-      datasetName,
-      DatasetListPage.SELECTORS.DELETE_ACTION,
-    );
+    const row = this.table.getRow(datasetName);
+    await row
+      .getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DELETE })
+      .click();
+  }
+
+  /**
+   * Clicks the edit action button for a dataset
+   * @param datasetName - The name of the dataset to edit
+   */
+  async clickEditAction(datasetName: string): Promise<void> {
+    const row = this.table.getRow(datasetName);
+    await row
+      .getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EDIT })
+      .click();
   }
 
   /**
@@ -96,10 +117,10 @@ export class DatasetListPage {
    * @param datasetName - The name of the dataset to export
    */
   async clickExportAction(datasetName: string): Promise<void> {
-    await this.table.clickRowAction(
-      datasetName,
-      DatasetListPage.SELECTORS.EXPORT_ACTION,
-    );
+    const row = this.table.getRow(datasetName);
+    await row
+      .getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EXPORT })
+      .click();
   }
 
   /**
@@ -107,9 +128,57 @@ export class DatasetListPage {
    * @param datasetName - The name of the dataset to duplicate
    */
   async clickDuplicateAction(datasetName: string): Promise<void> {
-    await this.table.clickRowAction(
-      datasetName,
-      DatasetListPage.SELECTORS.DUPLICATE_ACTION,
+    const row = this.table.getRow(datasetName);
+    await row
+      .getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DUPLICATE })
+      .click();
+  }
+
+  /**
+   * Clicks the "Bulk select" button to enable bulk selection mode
+   */
+  async clickBulkSelectButton(): Promise<void> {
+    await this.bulkSelect.enable();
+  }
+
+  /**
+   * Selects a dataset's checkbox in bulk select mode
+   * @param datasetName - The name of the dataset to select
+   */
+  async selectDatasetCheckbox(datasetName: string): Promise<void> {
+    await this.bulkSelect.selectRow(datasetName);
+  }
+
+  /**
+   * Clicks a bulk action button by name (e.g., "Export", "Delete")
+   * @param actionName - The name of the bulk action to click
+   */
+  async clickBulkAction(actionName: string): Promise<void> {
+    await this.bulkSelect.clickAction(actionName);
+  }
+
+  /**
+   * Gets the "+ Dataset" button for creating new datasets.
+   * Uses specific selector to avoid matching the "Datasets" nav link.
+   */
+  getAddDatasetButton(): Button {
+    return new Button(
+      this.page,
+      this.page.getByRole('button', { name: /^\+ Dataset$|^plus Dataset$/ }),
     );
   }
+
+  /**
+   * Clicks the "+ Dataset" button to navigate to create dataset page
+   */
+  async clickAddDataset(): Promise<void> {
+    await this.getAddDatasetButton().click();
+  }
+
+  /**
+   * Clicks the import button to open the import modal
+   */
+  async clickImportButton(): Promise<void> {
+    await this.page.getByTestId('import-button').click();
+  }
 }
diff --git 
a/superset-frontend/playwright/tests/experimental/dataset/create-dataset.spec.ts
 
b/superset-frontend/playwright/tests/experimental/dataset/create-dataset.spec.ts
new file mode 100644
index 00000000000..4250ddcb674
--- /dev/null
+++ 
b/superset-frontend/playwright/tests/experimental/dataset/create-dataset.spec.ts
@@ -0,0 +1,219 @@
+/**
+ * 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 '../../../helpers/fixtures/testAssets';
+import type { TestAssets } from '../../../helpers/fixtures/testAssets';
+import type { Page, TestInfo } from '@playwright/test';
+import { ExplorePage } from '../../../pages/ExplorePage';
+import { CreateDatasetPage } from '../../../pages/CreateDatasetPage';
+import { DatasetListPage } from '../../../pages/DatasetListPage';
+import { ChartCreationPage } from '../../../pages/ChartCreationPage';
+import { ENDPOINTS } from '../../../helpers/api/dataset';
+import { waitForPost } from '../../../helpers/api/intercepts';
+import { expectStatusOneOf } from '../../../helpers/api/assertions';
+import { apiPostDatabase } from '../../../helpers/api/database';
+
+interface GsheetsSetupResult {
+  sheetName: string;
+  dbName: string;
+  createDatasetPage: CreateDatasetPage;
+}
+
+/**
+ * Sets up gsheets database and navigates to create dataset page.
+ * Skips test if gsheets connector unavailable (test.skip() throws, so no 
return).
+ * @param testInfo - Test info for parallelIndex to avoid name collisions in 
parallel runs
+ * @returns Setup result with names and page object
+ */
+async function setupGsheetsDataset(
+  page: Page,
+  testAssets: TestAssets,
+  testInfo: TestInfo,
+): Promise<GsheetsSetupResult> {
+  // Public Google Sheet for testing (published to web, no auth required).
+  // This is a Netflix dataset that is publicly accessible via the Google 
Visualization API.
+  // NOTE: This sheet is hosted on an external Google account and is not 
created by the test itself.
+  // If this sheet is deleted, its ID changes, or its sharing settings are 
restricted,
+  // these tests will start failing when they attempt to create a database 
pointing at it.
+  // In that case, create or select a new publicly readable test sheet, update 
`sheetUrl`
+  // to use its URL, and update this comment to describe who owns/maintains 
that sheet
+  // and the expected access controls (e.g., "anyone with the link can view").
+  const sheetUrl =
+    
'https://docs.google.com/spreadsheets/d/19XNqckHGKGGPh83JGFdFGP4Bw9gdXeujq5EoIGwttdM/edit#gid=347941303';
+  // Include parallelIndex to avoid collisions when tests run in parallel
+  const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
+  const sheetName = `test_netflix_${uniqueSuffix}`;
+  const dbName = `test_gsheets_db_${uniqueSuffix}`;
+
+  // Create a Google Sheets database via API
+  // The catalog must be in `extra` as JSON with engine_params.catalog format
+  const catalogDict = { [sheetName]: sheetUrl };
+  const createDbRes = await apiPostDatabase(page, {
+    database_name: dbName,
+    engine: 'gsheets',
+    sqlalchemy_uri: 'gsheets://',
+    configuration_method: 'dynamic_form',
+    expose_in_sqllab: true,
+    extra: JSON.stringify({
+      engine_params: {
+        catalog: catalogDict,
+      },
+    }),
+  });
+
+  // Check if gsheets connector is available
+  if (!createDbRes.ok()) {
+    const errorBody = await createDbRes.json();
+    const errorText = JSON.stringify(errorBody);
+    // Skip test if gsheets connector not installed
+    if (
+      errorText.includes('gsheets') ||
+      errorText.includes('No such DB engine')
+    ) {
+      await test.info().attach('skip-reason', {
+        body: `Google Sheets connector unavailable: ${errorText}`,
+        contentType: 'text/plain',
+      });
+      test.skip(); // throws, no return needed
+    }
+    throw new Error(`Failed to create gsheets database: ${errorText}`);
+  }
+
+  const createDbBody = await createDbRes.json();
+  const dbId = createDbBody.result?.id ?? createDbBody.id;
+  if (!dbId) {
+    throw new Error('Database creation did not return an ID');
+  }
+  testAssets.trackDatabase(dbId);
+
+  // Navigate to create dataset page
+  const createDatasetPage = new CreateDatasetPage(page);
+  await createDatasetPage.goto();
+  await createDatasetPage.waitForPageLoad();
+
+  // Select the Google Sheets database
+  await createDatasetPage.selectDatabase(dbName);
+
+  // Try to select the sheet - if not found due to timeout, skip
+  try {
+    await createDatasetPage.selectTable(sheetName);
+  } catch (error) {
+    // Only skip on TimeoutError (sheet not loaded); re-throw everything else
+    if (!(error instanceof Error) || error.name !== 'TimeoutError') {
+      throw error;
+    }
+    await test.info().attach('skip-reason', {
+      body: `Table "${sheetName}" not found in dropdown after timeout.`,
+      contentType: 'text/plain',
+    });
+    test.skip(); // throws, no return needed
+  }
+
+  return { sheetName, dbName, createDatasetPage };
+}
+
+test('should create a dataset via wizard', async ({ page, testAssets }) => {
+  const { sheetName, createDatasetPage } = await setupGsheetsDataset(
+    page,
+    testAssets,
+    test.info(),
+  );
+
+  // Set up response intercept to capture new dataset ID
+  const createResponsePromise = waitForPost(page, ENDPOINTS.DATASET, {
+    pathMatch: true,
+  });
+
+  // Click "Create and explore dataset" button
+  await createDatasetPage.clickCreateAndExploreDataset();
+
+  // Wait for dataset creation and capture ID for cleanup
+  const createResponse = expectStatusOneOf(
+    await createResponsePromise,
+    [200, 201],
+  );
+  const createBody = await createResponse.json();
+  const newDatasetId = createBody.result?.id ?? createBody.id;
+
+  if (newDatasetId) {
+    testAssets.trackDataset(newDatasetId);
+  }
+
+  // Verify we navigated to Chart Creation page with dataset pre-selected
+  await page.waitForURL(/.*\/chart\/add.*/);
+  const chartCreationPage = new ChartCreationPage(page);
+  await chartCreationPage.waitForPageLoad();
+
+  // Verify the dataset is pre-selected
+  await chartCreationPage.expectDatasetSelected(sheetName);
+
+  // Select a visualization type and create chart
+  await chartCreationPage.selectVizType('Table');
+
+  // Click "Create new chart" to go to Explore
+  await chartCreationPage.clickCreateNewChart();
+
+  // Verify we navigated to Explore page
+  await page.waitForURL(/.*\/explore\/.*/);
+  const explorePage = new ExplorePage(page);
+  await explorePage.waitForPageLoad();
+
+  // Verify the dataset name is shown in Explore
+  const loadedDatasetName = await explorePage.getDatasetName();
+  expect(loadedDatasetName).toContain(sheetName);
+});
+
+test('should create a dataset without exploring', async ({
+  page,
+  testAssets,
+}) => {
+  const { sheetName, createDatasetPage } = await setupGsheetsDataset(
+    page,
+    testAssets,
+    test.info(),
+  );
+
+  // Set up response intercept to capture dataset ID
+  const createResponsePromise = waitForPost(page, ENDPOINTS.DATASET, {
+    pathMatch: true,
+  });
+
+  // Click "Create dataset" (not explore)
+  await createDatasetPage.clickCreateDataset();
+
+  // Capture dataset ID from response for cleanup
+  const createResponse = expectStatusOneOf(
+    await createResponsePromise,
+    [200, 201],
+  );
+  const createBody = await createResponse.json();
+  const datasetId = createBody.result?.id ?? createBody.id;
+  if (datasetId) {
+    testAssets.trackDataset(datasetId);
+  }
+
+  // Verify redirect to dataset list (not chart creation)
+  // Note: "Create dataset" action does not show a toast
+  await page.waitForURL(/.*tablemodelview\/list.*/);
+
+  // Wait for table load, verify row visible
+  const datasetListPage = new DatasetListPage(page);
+  await datasetListPage.waitForTableLoad();
+  await expect(datasetListPage.getDatasetRow(sheetName)).toBeVisible();
+});
diff --git 
a/superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts 
b/superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts
index 400934f4cce..5afa350e58c 100644
--- 
a/superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts
+++ 
b/superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts
@@ -17,76 +17,91 @@
  * under the License.
  */
 
-import { test, expect } from '@playwright/test';
+import {
+  test as testWithAssets,
+  expect,
+} from '../../../helpers/fixtures/testAssets';
+import type { Response } from '@playwright/test';
+import path from 'path';
+import * as unzipper from 'unzipper';
 import { DatasetListPage } from '../../../pages/DatasetListPage';
 import { ExplorePage } from '../../../pages/ExplorePage';
+import { ConfirmDialog } from '../../../components/modals/ConfirmDialog';
 import { DeleteConfirmationModal } from 
'../../../components/modals/DeleteConfirmationModal';
+import { ImportDatasetModal } from 
'../../../components/modals/ImportDatasetModal';
 import { DuplicateDatasetModal } from 
'../../../components/modals/DuplicateDatasetModal';
+import { EditDatasetModal } from '../../../components/modals/EditDatasetModal';
 import { Toast } from '../../../components/core/Toast';
 import {
   apiDeleteDataset,
   apiGetDataset,
+  apiPostVirtualDataset,
   getDatasetByName,
-  createTestVirtualDataset,
   ENDPOINTS,
 } from '../../../helpers/api/dataset';
+import { createTestDataset } from './dataset-test-helpers';
+import {
+  waitForGet,
+  waitForPost,
+  waitForPut,
+} from '../../../helpers/api/intercepts';
+import { expectStatusOneOf } from '../../../helpers/api/assertions';
+import { TIMEOUT } from '../../../utils/constants';
 
 /**
- * Test data constants
- * PHYSICAL_DATASET: A physical dataset from examples (for navigation tests)
- * Tests that need virtual datasets (duplicate/delete) create their own 
hermetic data
+ * Extend testWithAssets with datasetListPage navigation (beforeEach 
equivalent).
  */
-const TEST_DATASETS = {
-  /** Physical dataset for basic navigation tests */
-  PHYSICAL_DATASET: 'birth_names',
-} as const;
+const test = testWithAssets.extend<{ datasetListPage: DatasetListPage }>({
+  datasetListPage: async ({ page }, use) => {
+    const datasetListPage = new DatasetListPage(page);
+    await datasetListPage.goto();
+    await datasetListPage.waitForTableLoad();
+    await use(datasetListPage);
+  },
+});
 
 /**
- * Dataset List E2E Tests
- *
- * Uses flat test() structure per project convention (matches login.spec.ts).
- * Shared state and hooks are at file scope.
+ * Helper to validate an export zip response.
+ * Verifies headers, parses zip contents, and validates expected structure.
  */
+async function expectValidExportZip(
+  response: Response,
+  options: { minDatasetCount?: number; checkContentDisposition?: boolean } = 
{},
+): Promise<void> {
+  const { minDatasetCount = 1, checkContentDisposition = false } = options;
+
+  // Verify headers
+  expect(response.headers()['content-type']).toContain('application/zip');
+  if (checkContentDisposition) {
+    expect(response.headers()['content-disposition']).toMatch(
+      /filename=.*dataset_export.*\.zip/,
+    );
+  }
 
-// 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
+  // Parse and validate zip contents
+  const body = await response.body();
+  expect(body.length).toBeGreaterThan(0);
 
-  // Navigate to dataset list page
-  await datasetListPage.goto();
-  await datasetListPage.waitForTableLoad();
-});
+  const entries: string[] = [];
+  const directory = await unzipper.Open.buffer(body);
+  directory.files.forEach(file => entries.push(file.path));
 
-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);
-});
+  // Validate structure
+  const datasetYamlFiles = entries.filter(
+    entry => entry.includes('datasets/') && entry.endsWith('.yaml'),
+  );
+  expect(datasetYamlFiles.length).toBeGreaterThanOrEqual(minDatasetCount);
+  expect(entries.some(entry => entry.endsWith('metadata.yaml'))).toBe(true);
+}
 
 test('should navigate to Explore when dataset name is clicked', async ({
   page,
+  datasetListPage,
 }) => {
-  // Use existing physical dataset (loaded in CI via --load-examples)
-  const datasetName = TEST_DATASETS.PHYSICAL_DATASET;
+  const explorePage = new ExplorePage(page);
+
+  // Use existing example dataset (hermetic - loaded in CI via --load-examples)
+  const datasetName = 'members_channels_2';
   const dataset = await getDatasetByName(page, datasetName);
   expect(dataset).not.toBeNull();
 
@@ -108,16 +123,20 @@ test('should navigate to Explore when dataset name is 
clicked', async ({
   await expect(explorePage.getVizSwitcher()).toContainText('Table');
 });
 
-test('should delete a dataset with confirmation', async ({ page }) => {
-  // Create a virtual dataset for this test (hermetic - no dependency on 
examples)
-  const datasetName = `test_delete_${Date.now()}`;
-  const datasetId = await createTestVirtualDataset(page, datasetName);
-  expect(datasetId).not.toBeNull();
-
-  // Track for cleanup in case test fails partway through
-  testResources = { datasetIds: [datasetId!] };
+test('should delete a dataset with confirmation', async ({
+  page,
+  datasetListPage,
+  testAssets,
+}) => {
+  // Create throwaway dataset for deletion
+  const { id: datasetId, name: datasetName } = await createTestDataset(
+    page,
+    testAssets,
+    test.info(),
+    { prefix: 'test_delete' },
+  );
 
-  // Refresh page to see new dataset
+  // Refresh to see the new dataset
   await datasetListPage.goto();
   await datasetListPage.waitForTableLoad();
 
@@ -148,31 +167,44 @@ test('should delete a dataset with confirmation', async 
({ page }) => {
 
   // Verify dataset is removed from list
   await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
-});
-
-test('should duplicate a dataset with new name', async ({ page }) => {
-  // Create a virtual dataset for this test (hermetic - no dependency on 
examples)
-  const originalName = `test_original_${Date.now()}`;
-  const originalId = await createTestVirtualDataset(page, originalName);
-  expect(originalId).not.toBeNull();
 
-  // Track original for cleanup
-  testResources = { datasetIds: [originalId!] };
+  // Verify via API that dataset no longer exists (404)
+  await expect
+    .poll(
+      async () => {
+        const response = await apiGetDataset(page, datasetId, {
+          failOnStatusCode: false,
+        });
+        return response.status();
+      },
+      { timeout: 10000, message: `Dataset ${datasetId} should return 404` },
+    )
+    .toBe(404);
+});
 
-  const duplicateName = `duplicate_${originalName}`;
+test('should duplicate a dataset with new name', async ({
+  page,
+  datasetListPage,
+  testAssets,
+}) => {
+  // Create a virtual dataset first (duplicate UI only works for virtual 
datasets)
+  const { id: originalId, name: originalName } = await createTestDataset(
+    page,
+    testAssets,
+    test.info(),
+    { prefix: 'test_duplicate_source' },
+  );
+  const duplicateName = `duplicate_${Date.now()}_${test.info().parallelIndex}`;
 
-  // Refresh page to see new dataset
+  // Navigate to list and verify original dataset is visible
   await datasetListPage.goto();
   await datasetListPage.waitForTableLoad();
-
-  // 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,
+  const duplicateResponsePromise = waitForPost(
+    page,
+    ENDPOINTS.DATASET_DUPLICATE,
   );
 
   // Click duplicate action button
@@ -188,13 +220,17 @@ test('should duplicate a dataset with new name', async ({ 
page }) => {
   // Click the Duplicate button
   await duplicateModal.clickDuplicate();
 
-  // Get the duplicate dataset ID from response
-  const duplicateResponse = await duplicateResponsePromise;
+  // Get the duplicate dataset ID from response (handle both response shapes)
+  const duplicateResponse = expectStatusOneOf(
+    await duplicateResponsePromise,
+    [200, 201],
+  );
   const duplicateData = await duplicateResponse.json();
-  const duplicateId = duplicateData.id;
+  const duplicateId = duplicateData.result?.id ?? duplicateData.id;
+  expect(duplicateId, 'Duplicate API should return dataset id').toBeTruthy();
 
-  // Track both original and duplicate for cleanup
-  testResources = { datasetIds: [originalId!, duplicateId] };
+  // Track duplicate for cleanup (original is already tracked by 
createTestDataset)
+  testAssets.trackDataset(duplicateId);
 
   // Modal should close
   await duplicateModal.waitForHidden();
@@ -210,17 +246,437 @@ test('should duplicate a dataset with new name', async 
({ page }) => {
   await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
   await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
 
-  // API Verification: Compare original and duplicate datasets
-  const originalResponseData = await apiGetDataset(page, originalId!);
-  const originalDataFull = await originalResponseData.json();
-  const duplicateResponseData = await apiGetDataset(page, duplicateId);
-  const duplicateDataFull = await duplicateResponseData.json();
+  // API Verification: Fetch both datasets via detail API for consistent 
comparison
+  // (list API may return undefined for fields that detail API returns as null)
+  const [originalDetailRes, duplicateDetailRes] = await Promise.all([
+    apiGetDataset(page, originalId),
+    apiGetDataset(page, duplicateId),
+  ]);
+  const originalDetail = (await originalDetailRes.json()).result;
+  const duplicateDetail = (await duplicateDetailRes.json()).result;
 
   // Verify key properties were copied correctly
-  expect(duplicateDataFull.result.sql).toBe(originalDataFull.result.sql);
-  expect(duplicateDataFull.result.database.id).toBe(
-    originalDataFull.result.database.id,
-  );
+  expect(duplicateDetail.sql).toBe(originalDetail.sql);
+  expect(duplicateDetail.database.id).toBe(originalDetail.database.id);
+  expect(duplicateDetail.schema).toBe(originalDetail.schema);
   // Name should be different (the duplicate name)
-  expect(duplicateDataFull.result.table_name).toBe(duplicateName);
+  expect(duplicateDetail.table_name).toBe(duplicateName);
+});
+
+test('should export a dataset as a zip file', async ({
+  page,
+  datasetListPage,
+}) => {
+  // Use existing example dataset
+  const datasetName = 'members_channels_2';
+  const dataset = await getDatasetByName(page, datasetName);
+  expect(dataset).not.toBeNull();
+
+  // Verify dataset is visible in list
+  await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
+
+  // Set up API response intercept for export endpoint
+  // Note: We intercept the API response instead of relying on download events 
because
+  // Superset uses blob downloads (createObjectURL) which don't trigger 
Playwright's
+  // download event consistently, especially in app-prefix configurations.
+  const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT);
+
+  // Click export action button
+  await datasetListPage.clickExportAction(datasetName);
+
+  // Wait for export API response and validate zip contents
+  const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
+  await expectValidExportZip(exportResponse, { checkContentDisposition: true 
});
+});
+
+test('should export multiple datasets via bulk select action', async ({
+  page,
+  datasetListPage,
+  testAssets,
+}) => {
+  // Create 2 throwaway datasets for bulk export
+  const [dataset1, dataset2] = await Promise.all([
+    createTestDataset(page, testAssets, test.info(), {
+      prefix: 'bulk_export_1',
+    }),
+    createTestDataset(page, testAssets, test.info(), {
+      prefix: 'bulk_export_2',
+    }),
+  ]);
+
+  // Refresh to see new datasets
+  await datasetListPage.goto();
+  await datasetListPage.waitForTableLoad();
+
+  // Verify both datasets are visible in list
+  await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
+  await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
+
+  // Enable bulk select mode
+  await datasetListPage.clickBulkSelectButton();
+
+  // Select both datasets
+  await datasetListPage.selectDatasetCheckbox(dataset1.name);
+  await datasetListPage.selectDatasetCheckbox(dataset2.name);
+
+  // Set up API response intercept for export endpoint
+  const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT);
+
+  // Click bulk export action
+  await datasetListPage.clickBulkAction('Export');
+
+  // Wait for export API response and validate zip contains multiple datasets
+  const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
+  await expectValidExportZip(exportResponse, { minDatasetCount: 2 });
+});
+
+test('should edit dataset name via modal', async ({
+  page,
+  datasetListPage,
+  testAssets,
+}) => {
+  // Create throwaway dataset for editing
+  const { id: datasetId, name: datasetName } = await createTestDataset(
+    page,
+    testAssets,
+    test.info(),
+    { prefix: 'test_edit' },
+  );
+
+  // Refresh to see new dataset
+  await datasetListPage.goto();
+  await datasetListPage.waitForTableLoad();
+
+  // Verify dataset is visible in list
+  await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
+
+  // Click edit action to open modal
+  await datasetListPage.clickEditAction(datasetName);
+
+  // Wait for edit modal to be ready
+  const editModal = new EditDatasetModal(page);
+  await editModal.waitForReady();
+
+  // Enable edit mode by clicking the lock icon
+  await editModal.enableEditMode();
+
+  // Edit the dataset name
+  const newName = `test_renamed_${Date.now()}`;
+  await editModal.fillName(newName);
+
+  // Set up response intercept for save
+  const saveResponsePromise = waitForPut(
+    page,
+    `${ENDPOINTS.DATASET}${datasetId}`,
+  );
+
+  // Click Save button
+  await editModal.clickSave();
+
+  // Handle the "Confirm save" dialog that may appear for datasets with sync 
columns enabled
+  const confirmDialog = new ConfirmDialog(page);
+  await confirmDialog.clickOk({ timeout: TIMEOUT.CONFIRM_DIALOG });
+
+  // Wait for save to complete and verify success
+  expectStatusOneOf(await saveResponsePromise, [200, 201]);
+
+  // Modal should close
+  await editModal.waitForHidden();
+
+  // Verify success toast appears
+  const toast = new Toast(page);
+  await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
+
+  // Verify via API that name was saved
+  const updatedDatasetRes = await apiGetDataset(page, datasetId);
+  const updatedDataset = (await updatedDatasetRes.json()).result;
+  expect(updatedDataset.table_name).toBe(newName);
+});
+
+test('should bulk delete multiple datasets', async ({
+  page,
+  datasetListPage,
+  testAssets,
+}) => {
+  // Create 2 throwaway datasets for bulk delete
+  const [dataset1, dataset2] = await Promise.all([
+    createTestDataset(page, testAssets, test.info(), {
+      prefix: 'bulk_delete_1',
+    }),
+    createTestDataset(page, testAssets, test.info(), {
+      prefix: 'bulk_delete_2',
+    }),
+  ]);
+
+  // Refresh to see new datasets
+  await datasetListPage.goto();
+  await datasetListPage.waitForTableLoad();
+
+  // Verify both datasets are visible in list
+  await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
+  await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
+
+  // Enable bulk select mode
+  await datasetListPage.clickBulkSelectButton();
+
+  // Select both datasets
+  await datasetListPage.selectDatasetCheckbox(dataset1.name);
+  await datasetListPage.selectDatasetCheckbox(dataset2.name);
+
+  // Click bulk delete action
+  await datasetListPage.clickBulkAction('Delete');
+
+  // 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
+  const toast = new Toast(page);
+  await expect(toast.getSuccess()).toBeVisible();
+
+  // Verify both datasets are removed from list
+  await expect(datasetListPage.getDatasetRow(dataset1.name)).not.toBeVisible();
+  await expect(datasetListPage.getDatasetRow(dataset2.name)).not.toBeVisible();
+
+  // Verify via API that datasets no longer exist (404)
+  // Use polling with explicit timeout since deletes may be async
+  await expect
+    .poll(
+      async () => {
+        const response = await apiGetDataset(page, dataset1.id, {
+          failOnStatusCode: false,
+        });
+        return response.status();
+      },
+      { timeout: 10000, message: `Dataset ${dataset1.id} should return 404` },
+    )
+    .toBe(404);
+  await expect
+    .poll(
+      async () => {
+        const response = await apiGetDataset(page, dataset2.id, {
+          failOnStatusCode: false,
+        });
+        return response.status();
+      },
+      { timeout: 10000, message: `Dataset ${dataset2.id} should return 404` },
+    )
+    .toBe(404);
+});
+
+// Import test uses a fixed dataset name from the zip fixture.
+// Uses test.describe only because Playwright's serial mode API requires it -
+// this prevents race conditions when parallel workers import the same fixture.
+// (Deviation from "avoid describe" guideline is necessary for functional 
reasons)
+test.describe('import dataset', () => {
+  test.describe.configure({ mode: 'serial' });
+  test('should import a dataset from a zip file', async ({
+    page,
+    datasetListPage,
+    testAssets,
+  }) => {
+    // Dataset name from fixture (test_netflix_1768502050965)
+    // Note: Fixture contains a Google Sheets dataset - test will skip if 
gsheets connector unavailable
+    const importedDatasetName = 'test_netflix_1768502050965';
+    const fixturePath = path.resolve(
+      __dirname,
+      '../../../fixtures/dataset_export.zip',
+    );
+
+    // Cleanup: Delete any existing dataset with the same name from previous 
runs
+    const existingDataset = await getDatasetByName(page, importedDatasetName);
+    if (existingDataset) {
+      await apiDeleteDataset(page, existingDataset.id, {
+        failOnStatusCode: false,
+      });
+    }
+
+    // Click the import button
+    await datasetListPage.clickImportButton();
+
+    // Wait for import modal to be ready
+    const importModal = new ImportDatasetModal(page);
+    await importModal.waitForReady();
+
+    // Upload the fixture zip file
+    await importModal.uploadFile(fixturePath);
+
+    // Set up response intercept to catch the import POST
+    // Use pathMatch to avoid false matches if URL lacks trailing slash
+    let importResponsePromise = waitForPost(page, ENDPOINTS.DATASET_IMPORT, {
+      pathMatch: true,
+    });
+
+    // Click Import button
+    await importModal.clickImport();
+
+    // Wait for first import response
+    let importResponse = await importResponsePromise;
+
+    // Handle overwrite confirmation if dataset already exists
+    // First response may be 409/422 indicating overwrite is required - this 
is expected
+    const overwriteInput = importModal.getOverwriteInput();
+    await overwriteInput
+      .waitFor({ state: 'visible', timeout: 3000 })
+      .catch(error => {
+        // Only ignore TimeoutError (input not visible); re-throw other errors
+        if (!(error instanceof Error) || error.name !== 'TimeoutError') {
+          throw error;
+        }
+      });
+
+    if (await overwriteInput.isVisible()) {
+      // Set up new intercept for the actual import after overwrite 
confirmation
+      importResponsePromise = waitForPost(page, ENDPOINTS.DATASET_IMPORT, {
+        pathMatch: true,
+      });
+      await importModal.fillOverwriteConfirmation();
+      await importModal.clickImport();
+      // Wait for the second (final) import response
+      importResponse = await importResponsePromise;
+    }
+
+    // Check final import response for gsheets connector errors
+    if (!importResponse.ok()) {
+      const errorBody = await importResponse.json().catch(() => ({}));
+      const errorText = JSON.stringify(errorBody);
+      // Skip test if gsheets connector not installed
+      if (
+        errorText.includes('gsheets') ||
+        errorText.includes('No such DB engine') ||
+        errorText.includes('Could not load database driver')
+      ) {
+        await test.info().attach('skip-reason', {
+          body: `Import failed due to missing gsheets connector: ${errorText}`,
+          contentType: 'text/plain',
+        });
+        test.skip();
+        return;
+      }
+      // Re-throw other errors
+      throw new Error(`Import failed: ${errorText}`);
+    }
+
+    // Modal should close on success
+    await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
+
+    // Verify success toast appears
+    const toast = new Toast(page);
+    await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
+
+    // Refresh the page to see the imported dataset
+    await datasetListPage.goto();
+    await datasetListPage.waitForTableLoad();
+
+    // Verify dataset appears in list
+    await expect(
+      datasetListPage.getDatasetRow(importedDatasetName),
+    ).toBeVisible();
+
+    // Get dataset ID for cleanup
+    const importedDataset = await getDatasetByName(page, importedDatasetName);
+    expect(importedDataset).not.toBeNull();
+    testAssets.trackDataset(importedDataset!.id);
+  });
+});
+
+test('should edit column date format via modal', async ({
+  page,
+  datasetListPage,
+  testAssets,
+}) => {
+  // Create virtual dataset with a date column for testing
+  // Using SQL to create a dataset with 'ds' column avoids duplication issues
+  const datasetName = 
`test_date_format_${Date.now()}_${test.info().parallelIndex}`;
+  const baseDataset = await getDatasetByName(page, 'members_channels_2');
+  expect(baseDataset, 'members_channels_2 dataset must exist').not.toBeNull();
+
+  const createResponse = await apiPostVirtualDataset(page, {
+    database: baseDataset!.database.id,
+    schema: baseDataset!.schema ?? null,
+    table_name: datasetName,
+    sql: "SELECT CAST('2024-01-01' AS DATE) as ds, 'test' as name",
+  });
+  expectStatusOneOf(createResponse, [200, 201]);
+  const createBody = await createResponse.json();
+  const datasetId = createBody.result?.id ?? createBody.id;
+  expect(datasetId, 'Virtual dataset creation should return id').toBeTruthy();
+  testAssets.trackDataset(datasetId);
+
+  // Navigate to dataset list, click edit action
+  await datasetListPage.goto();
+  await datasetListPage.waitForTableLoad();
+  await datasetListPage.clickEditAction(datasetName);
+
+  // Enable edit mode, navigate to Columns tab
+  const editModal = new EditDatasetModal(page);
+  await editModal.waitForReady();
+  await editModal.enableEditMode();
+  await editModal.clickColumnsTab();
+
+  // Expand 'ds' column row and fill date format (scoped to row)
+  const dateFormat = '%Y-%m-%d';
+  await editModal.fillColumnDateFormat('ds', dateFormat);
+
+  // Save and handle confirmation dialog conditionally
+  await editModal.clickSave();
+  await new ConfirmDialog(page).clickOk({ timeout: TIMEOUT.CONFIRM_DIALOG });
+  await editModal.waitForHidden();
+
+  // Verify via API
+  const updatedRes = await apiGetDataset(page, datasetId);
+  const columns = (await updatedRes.json()).result.columns;
+  const dsColumn = columns.find(
+    (c: { column_name: string }) => c.column_name === 'ds',
+  );
+  expect(dsColumn, 'ds column should exist in dataset').toBeDefined();
+  expect(dsColumn.python_date_format).toBe(dateFormat);
+});
+
+test('should edit dataset description via modal', async ({
+  page,
+  datasetListPage,
+  testAssets,
+}) => {
+  // Create throwaway dataset for editing description
+  const { id: datasetId, name: datasetName } = await createTestDataset(
+    page,
+    testAssets,
+    test.info(),
+    { prefix: 'test_description' },
+  );
+
+  // Navigate to dataset list, click edit action
+  await datasetListPage.goto();
+  await datasetListPage.waitForTableLoad();
+  await datasetListPage.clickEditAction(datasetName);
+
+  // Enable edit mode, navigate to Settings tab
+  const editModal = new EditDatasetModal(page);
+  await editModal.waitForReady();
+  await editModal.enableEditMode();
+  await editModal.clickSettingsTab();
+
+  // Fill description field
+  const description = `Test description ${Date.now()}`;
+  await editModal.fillDescription(description);
+
+  // Save and handle confirmation dialog conditionally
+  await editModal.clickSave();
+  await new ConfirmDialog(page).clickOk({ timeout: TIMEOUT.CONFIRM_DIALOG });
+  await editModal.waitForHidden();
+
+  // Verify via API
+  const updatedRes = await apiGetDataset(page, datasetId);
+  const result = (await updatedRes.json()).result;
+  expect(result.description).toBe(description);
 });
diff --git 
a/superset-frontend/playwright/tests/experimental/dataset/dataset-test-helpers.ts
 
b/superset-frontend/playwright/tests/experimental/dataset/dataset-test-helpers.ts
new file mode 100644
index 00000000000..8f619580ae8
--- /dev/null
+++ 
b/superset-frontend/playwright/tests/experimental/dataset/dataset-test-helpers.ts
@@ -0,0 +1,67 @@
+/**
+ * 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 type { Page, TestInfo } from '@playwright/test';
+import type { TestAssets } from '../../../helpers/fixtures/testAssets';
+import { createTestVirtualDataset } from '../../../helpers/api/dataset';
+
+interface TestDatasetResult {
+  id: number;
+  name: string;
+}
+
+interface CreateTestDatasetOptions {
+  /** Prefix for generated name (default: 'test') */
+  prefix?: string;
+}
+
+/**
+ * Creates a test virtual dataset.
+ * Uses createTestVirtualDataset() to create a simple virtual dataset for 
testing.
+ *
+ * Note: The dataset duplicate API only works with virtual datasets. This 
helper
+ * creates virtual datasets directly to avoid that limitation.
+ *
+ * @example
+ * // Basic usage
+ * const { id, name } = await createTestDataset(page, testAssets, test.info());
+ *
+ * @example
+ * // Custom prefix
+ * const { id, name } = await createTestDataset(page, testAssets, test.info(), 
{
+ *   prefix: 'test_delete',
+ * });
+ */
+export async function createTestDataset(
+  page: Page,
+  testAssets: TestAssets,
+  testInfo: TestInfo,
+  options?: CreateTestDatasetOptions,
+): Promise<TestDatasetResult> {
+  const prefix = options?.prefix ?? 'test';
+  const name = `${prefix}_${Date.now()}_${testInfo.parallelIndex}`;
+
+  const id = await createTestVirtualDataset(page, name);
+  if (!id) {
+    throw new Error(`Failed to create test dataset: ${name}`);
+  }
+  testAssets.trackDataset(id);
+
+  return { id, name };
+}
diff --git a/superset-frontend/playwright/utils/constants.ts 
b/superset-frontend/playwright/utils/constants.ts
index 02b1aea4571..7f882d9630d 100644
--- a/superset-frontend/playwright/utils/constants.ts
+++ b/superset-frontend/playwright/utils/constants.ts
@@ -48,4 +48,14 @@ export const TIMEOUT = {
    * API response timeout for operations like export/download
    */
   API_RESPONSE: 15000, // 15s for API responses and downloads
+
+  /**
+   * Confirmation dialog wait (e.g., "Confirm save", "Are you sure?")
+   */
+  CONFIRM_DIALOG: 2000, // 2s for confirmation dialogs that may or may not 
appear
+
+  /**
+   * File import/upload operations (upload + server processing)
+   */
+  FILE_IMPORT: 30000, // 30s for file import operations
 } as const;

Reply via email to