Copilot commented on code in PR #36684:
URL: https://github.com/apache/superset/pull/36684#discussion_r2722992134


##########
superset-frontend/playwright/components/modals/ConfirmDialog.ts:
##########
@@ -0,0 +1,78 @@
+/**
+ * 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
+   * Waits for element to be visible before clicking
+   */
+  async clickOk(): Promise<void> {
+    // Wait for modal to be visible before clicking
+    await this.element.waitFor({ state: 'visible' });
+    await this.clickFooterButton('OK');
+  }
+
+  /**
+   * Clicks the Cancel button to dismiss
+   */
+  async clickCancel(): Promise<void> {
+    await this.clickFooterButton('Cancel');
+  }
+
+  /**
+   * Clicks OK if dialog appears within timeout, otherwise silently returns.
+   * Use when dialog appearance is conditional (e.g., depends on dataset 
settings).
+   * @param timeout - How long to wait for dialog (default 2000ms)
+   */
+  async clickOkIfVisible(timeout = 2000): Promise<void> {
+    try {
+      await this.element.waitFor({ state: 'visible', timeout });
+      await this.clickOk();
+      await this.waitForHidden();
+    } catch (error) {
+      if (!(error instanceof Error) || error.name !== 'TimeoutError') {
+        throw error;
+      }

Review Comment:
   The error handling pattern here only catches TimeoutError but re-throws all 
other errors. However, the catch block checks if the error is an instance of 
Error before accessing the name property. If a non-Error object is thrown 
(e.g., a string or null), this check will succeed but error.name will be 
undefined, potentially causing unexpected behavior. Consider adding a more 
defensive check like: if (error?.name !== 'TimeoutError') throw error;



##########
superset-frontend/playwright/components/core/Textarea.ts:
##########
@@ -0,0 +1,92 @@
+/**
+ * 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';
+
+/**
+ * Textarea component for multi-line text input interactions.

Review Comment:
   The Textarea class is missing JSDoc documentation for the class itself, 
unlike other core component classes (Button, Input, Modal, etc.) in the 
codebase. Add a JSDoc comment describing the purpose and usage of this 
component for consistency with the documentation patterns established in other 
core components.
   ```suggestion
    * 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();
    * ```
   ```



##########
superset-frontend/playwright/components/core/Select.ts:
##########
@@ -0,0 +1,180 @@
+/**
+ * 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(() => {
+        // Dropdown may already be closed or never opened
+      });
+  }
+
+  /**
+   * 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 {
+      // Fallback: locator might be the input itself (e.g., from 
getByRole('combobox'))
+      await this.locator.fill(text);

Review Comment:
   The type method has a try-catch block that falls back to filling the locator 
directly if the searchInput is not found. However, the catch block is too broad 
and will catch any error (including network errors, JavaScript errors, etc.). 
Consider catching more specific errors or at least checking the error type 
before falling back to the alternative approach.
   ```suggestion
       } catch (error) {
         // Only fall back when we fail to find/attach the search input (e.g. 
timeout)
         if (error instanceof Error && /timeout/i.test(error.message)) {
           // Fallback: locator might be the input itself (e.g., from 
getByRole('combobox'))
           await this.locator.fill(text);
         } else {
           // Unexpected error - rethrow so tests fail visibly
           throw error;
         }
   ```



##########
superset-frontend/playwright/components/core/Checkbox.ts:
##########
@@ -0,0 +1,82 @@
+/**
+ * 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';
+
+/**
+ * Checkbox component for checkbox interactions.

Review Comment:
   The Checkbox class is missing JSDoc documentation for the class itself, 
unlike other core component classes (Button, Input, Modal, etc.) in the 
codebase. Add a JSDoc comment describing the purpose and usage of this 
component for consistency with the documentation patterns established in other 
core components.
   ```suggestion
    * 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.
   ```



##########
superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts:
##########
@@ -210,17 +230,431 @@ 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, original!.id),
+    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 appears for datasets with sync 
columns enabled
+  const confirmDialog = new ConfirmDialog(page);
+  await confirmDialog.clickOk();
+
+  // 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 since deletes may be async
+  await expect
+    .poll(async () => {
+      const response = await apiGetDataset(page, dataset1.id, {
+        failOnStatusCode: false,
+      });
+      return response.status();
+    })
+    .toBe(404);
+  await expect
+    .poll(async () => {
+      const response = await apiGetDataset(page, dataset2.id, {
+        failOnStatusCode: false,
+      });
+      return response.status();
+    })
+    .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: 30000 });
+
+    // 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 ?? '',

Review Comment:
   The schema field in VirtualDatasetCreatePayload is defined as 'string | 
null', but in the apiPostVirtualDataset call on line 583, it uses 
'baseDataset!.schema ?? '''. This means if schema is null, it will be converted 
to an empty string. However, the type definition allows null. Consider either: 
(1) passing null directly when schema is null to match the type signature, or 
(2) documenting why an empty string is preferred over null for the schema field.
   ```suggestion
       schema: baseDataset!.schema ?? null,
   ```



##########
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),

Review Comment:
   The testAssets fixture uses Set to de-duplicate IDs (line 36-37), which is 
good. However, if a test tracks the same ID multiple times (e.g., due to a bug 
or retry logic), the cleanup will only attempt deletion once. While this is 
generally fine, consider logging when duplicate IDs are tracked to help 
identify potential test issues during development.
   ```suggestion
         trackDataset: id => {
           if (datasetIds.has(id)) {
             console.warn(
               `[testAssets] Dataset ID ${id} is being tracked multiple times 
in the same test.`,
             );
           }
           datasetIds.add(id);
         },
         trackDatabase: id => {
           if (databaseIds.has(id)) {
             console.warn(
               `[testAssets] Database ID ${id} is being tracked multiple times 
in the same test.`,
             );
           }
           databaseIds.add(id);
         },
   ```



##########
superset-frontend/playwright.config.ts:
##########
@@ -117,10 +120,17 @@ 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
+        const healthUrl = new URL('health', baseUrl).href;
+        return {
+          command: `curl -f ${healthUrl}`,
+          url: healthUrl,
+          reuseExistingServer: true,
+          timeout: 5000,
+        };
+      })(),

Review Comment:
   The webServer configuration now extracts the base URL to derive the health 
check URL (lines 124-129). However, if PLAYWRIGHT_BASE_URL is set to a URL with 
a path prefix (e.g., 'http://localhost:9012/app/'), the health check will be 
sent to 'http://localhost:9012/app/health' instead of 
'http://localhost:9012/health'. Verify that the health endpoint respects path 
prefixes, or extract the origin (scheme + host + port) differently to ensure 
health checks work correctly with prefixed URLs.



##########
superset-frontend/playwright/components/modals/EditDatasetModal.ts:
##########
@@ -0,0 +1,184 @@
+/**
+ * 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]');

Review Comment:
   The EditDatasetModal.expandColumn method uses an XPath expression 
'xpath=ancestor::tr[1]' to find the row element from a cell (line 157). XPath 
selectors are generally more fragile than modern Playwright locators and can 
break if the DOM structure changes. Consider using a more robust selector 
strategy, such as cell.locator('..').locator('xpath=ancestor::tr[1]') or 
refactoring to use getByRole with table row relationships.
   ```suggestion
       const row = this.body.getByRole('row', { has: cell });
   ```



##########
superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts:
##########
@@ -210,17 +230,431 @@ 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, original!.id),
+    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 appears for datasets with sync 
columns enabled
+  const confirmDialog = new ConfirmDialog(page);
+  await confirmDialog.clickOk();
+
+  // 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 since deletes may be async
+  await expect
+    .poll(async () => {
+      const response = await apiGetDataset(page, dataset1.id, {
+        failOnStatusCode: false,
+      });
+      return response.status();
+    })
+    .toBe(404);
+  await expect
+    .poll(async () => {
+      const response = await apiGetDataset(page, dataset2.id, {
+        failOnStatusCode: false,
+      });
+      return response.status();
+    })

Review Comment:
   The bulk delete test uses polling with expect.poll() to verify datasets are 
deleted (lines 436-451). However, there's no maximum wait time specified, so it 
will use Playwright's default timeout. If the delete operation takes longer 
than expected or fails silently, the test could hang. Consider adding an 
explicit timeout to the poll operation to make test failures more predictable.
   ```suggestion
       .poll(
         async () => {
           const response = await apiGetDataset(page, dataset1.id, {
             failOnStatusCode: false,
           });
           return response.status();
         },
         { timeout: 30_000 },
       )
       .toBe(404);
     await expect
       .poll(
         async () => {
           const response = await apiGetDataset(page, dataset2.id, {
             failOnStatusCode: false,
           });
           return response.status();
         },
         { timeout: 30_000 },
       )
   ```



##########
superset-frontend/playwright/components/core/Select.ts:
##########
@@ -0,0 +1,180 @@
+/**
+ * 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(() => {
+        // Dropdown may already be closed or never opened
+      });

Review Comment:
   The waitForDropdownClose method catches all errors and silently ignores them 
with an empty catch block. This could hide legitimate errors unrelated to the 
dropdown already being closed. Consider being more specific about which errors 
to ignore, or at least log warnings for unexpected errors. For example, you 
could check if the error is a TimeoutError before ignoring it.



##########
superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts:
##########
@@ -17,76 +17,90 @@
  * 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';
 
 /**
- * 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;

Review Comment:
   The parameter name 'checkContentDisposition' is a boolean flag that controls 
whether to check content-disposition header. In the function implementation 
(line 75-77), when this flag is true, the header is checked. However, the 
default value is false (line 70), meaning by default this validation is 
skipped. Consider renaming to 'validateContentDisposition' or 
'verifyContentDisposition' for clarity, or document why this check is optional 
by default.



##########
superset-frontend/playwright/tests/experimental/dataset/create-dataset.spec.ts:
##########
@@ -0,0 +1,211 @@
+/**
+ * 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.
+ * @param testInfo - Test info for parallelIndex to avoid name collisions in 
parallel runs
+ * @returns Setup result with names and page object, or null if test.skip() 
was called
+ */
+async function setupGsheetsDataset(
+  page: Page,
+  testAssets: TestAssets,
+  testInfo: TestInfo,
+): Promise<GsheetsSetupResult | null> {
+  // Public Google Sheet for testing (published to web, no auth required)
+  // This is a Netflix dataset that is publicly accessible via Google 
Visualization API
+  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();
+      return null;
+    }
+    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') {

Review Comment:
   The error handling pattern here only catches TimeoutError but re-throws all 
other errors. However, the catch block checks if the error is an instance of 
Error before accessing the name property. If a non-Error object is thrown 
(e.g., a string or null), this check will succeed but error.name will be 
undefined, potentially causing unexpected behavior. Consider adding a more 
defensive check like: if (error?.name !== 'TimeoutError') throw error;
   ```suggestion
       if ((error as { name?: string } | null | undefined)?.name !== 
'TimeoutError') {
   ```



##########
superset-frontend/playwright/tests/experimental/dataset/create-dataset.spec.ts:
##########
@@ -0,0 +1,211 @@
+/**
+ * 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.
+ * @param testInfo - Test info for parallelIndex to avoid name collisions in 
parallel runs
+ * @returns Setup result with names and page object, or null if test.skip() 
was called
+ */
+async function setupGsheetsDataset(
+  page: Page,
+  testAssets: TestAssets,
+  testInfo: TestInfo,
+): Promise<GsheetsSetupResult | null> {
+  // Public Google Sheet for testing (published to web, no auth required)
+  // This is a Netflix dataset that is publicly accessible via Google 
Visualization API

Review Comment:
   The code uses a hardcoded public Google Sheets URL for testing. While the 
comment indicates this is a "public" sheet, consider documenting the ownership 
and access controls of this external resource. If this Google Sheet is deleted 
or made private in the future, these tests will fail. Consider adding a comment 
about who owns/maintains this sheet or creating a test-specific sheet owned by 
the project.
   ```suggestion
     // 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").
   ```



##########
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();

Review Comment:
   The CreateDatasetPage.clickCreateDataset method uses an XPath expression 
'xpath=following-sibling::button' to find the dropdown trigger button (line 
117). XPath selectors are generally more fragile than modern Playwright 
locators and can break if the DOM structure changes. Consider using a more 
robust selector strategy, such as data-test attributes or getByRole with more 
specific criteria, to make this locator more maintainable.
   ```suggestion
       // The dropdown trigger is in the same button group: go to the parent 
container
       // and select the second button in that group (index 1).
       const dropdownTrigger = mainButton
         .locator('..')
         .getByRole('button')
         .nth(1);
   ```



##########
superset-frontend/playwright/components/core/AceEditor.ts:
##########
@@ -0,0 +1,200 @@
+/**
+ * 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' });

Review Comment:
   The AceEditor methods (setText, getText, appendText, focus) all use 
page.evaluate to directly access the ace global object. If the ace library is 
not loaded or takes time to load, these methods will fail with 'Ace editor 
library not loaded' error. While waitForReady() checks for DOM elements, it 
doesn't verify that the ace JavaScript library is actually loaded. Consider 
adding a check in waitForReady() to poll for window.ace availability before 
proceeding with editor operations.
   ```suggestion
         .waitFor({ state: 'attached' });
   
       // Additionally ensure the Ace library is loaded in the page before 
proceeding.
       await this.page.waitForFunction(() => {
         const win = window as unknown as {
           ace?: { edit?: unknown };
         };
         return !!(win.ace && typeof win.ace.edit === 'function');
       });
   ```



##########
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);
+}

Review Comment:
   The matchUrl function uses new URL(url) to parse URLs when pathMatch is 
enabled (line 65). If the url parameter is not a valid URL (e.g., a relative 
path), this will throw an error. While this is likely intentional to catch 
malformed URLs, consider adding error handling or validation to provide a more 
helpful error message when an invalid URL is encountered.



##########
superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts:
##########
@@ -210,17 +230,431 @@ 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, original!.id),
+    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 appears for datasets with sync 
columns enabled
+  const confirmDialog = new ConfirmDialog(page);
+  await confirmDialog.clickOk();
+
+  // 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 since deletes may be async
+  await expect
+    .poll(async () => {
+      const response = await apiGetDataset(page, dataset1.id, {
+        failOnStatusCode: false,
+      });
+      return response.status();
+    })
+    .toBe(404);
+  await expect
+    .poll(async () => {
+      const response = await apiGetDataset(page, dataset2.id, {
+        failOnStatusCode: false,
+      });
+      return response.status();
+    })
+    .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;
+        }
+      });

Review Comment:
   The error handling pattern here only catches TimeoutError but re-throws all 
other errors. However, the catch block checks if the error is an instance of 
Error before accessing the name property. If a non-Error object is thrown 
(e.g., a string or null), this check will succeed but error.name will be 
undefined, potentially causing unexpected behavior. Consider adding a type 
guard or using a more defensive check like: if (error?.name !== 'TimeoutError') 
throw error;



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to