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;