From 5040db859c8e2342e52e583ddea31e4d581c7cfd Mon Sep 17 00:00:00 2001 From: Joe Li Date: Thu, 5 Feb 2026 16:42:07 -0800 Subject: [PATCH] test(playwright): additional dataset list playwright tests (#36684) Co-authored-by: Claude Opus 4.5 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- superset-frontend/package-lock.json | 26 + superset-frontend/package.json | 2 + superset-frontend/playwright.config.ts | 24 +- .../components/ListView/BulkSelect.ts | 116 ++++ .../playwright/components/ListView/index.ts | 21 + .../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 + .../components/modals/ConfirmDialog.ts | 75 +++ .../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 ++++ .../playwright/helpers/fixtures/index.ts | 21 + .../playwright/helpers/fixtures/testAssets.ts | 68 ++ .../playwright/pages/ChartCreationPage.ts | 138 ++++ .../playwright/pages/CreateDatasetPage.ts | 138 ++++ .../playwright/pages/DatasetListPage.ts | 99 ++- .../dataset/create-dataset.spec.ts | 219 ++++++ .../experimental/dataset/dataset-list.spec.ts | 634 +++++++++++++++--- .../dataset/dataset-test-helpers.ts | 67 ++ .../playwright/utils/constants.ts | 10 + 30 files changed, 2828 insertions(+), 125 deletions(-) create mode 100644 superset-frontend/playwright/components/ListView/BulkSelect.ts create mode 100644 superset-frontend/playwright/components/ListView/index.ts create mode 100644 superset-frontend/playwright/components/core/AceEditor.ts create mode 100644 superset-frontend/playwright/components/core/Checkbox.ts create mode 100644 superset-frontend/playwright/components/core/Select.ts create mode 100644 superset-frontend/playwright/components/core/Tabs.ts create mode 100644 superset-frontend/playwright/components/core/Textarea.ts create mode 100644 superset-frontend/playwright/components/modals/ConfirmDialog.ts create mode 100644 superset-frontend/playwright/components/modals/EditDatasetModal.ts create mode 100644 superset-frontend/playwright/components/modals/ImportDatasetModal.ts create mode 100644 superset-frontend/playwright/fixtures/dataset_export.zip create mode 100644 superset-frontend/playwright/helpers/api/assertions.ts create mode 100644 superset-frontend/playwright/helpers/api/intercepts.ts create mode 100644 superset-frontend/playwright/helpers/fixtures/index.ts create mode 100644 superset-frontend/playwright/helpers/fixtures/testAssets.ts create mode 100644 superset-frontend/playwright/pages/ChartCreationPage.ts create mode 100644 superset-frontend/playwright/pages/CreateDatasetPage.ts create mode 100644 superset-frontend/playwright/tests/experimental/dataset/create-dataset.spec.ts create mode 100644 superset-frontend/playwright/tests/experimental/dataset/dataset-test-helpers.ts 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 { + 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 { + 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 { + 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 { + await this.getActionButton(actionName).click(); + } +} diff --git a/superset-frontend/playwright/components/ListView/index.ts b/superset-frontend/playwright/components/ListView/index.ts new file mode 100644 index 00000000000..09bd815d4db --- /dev/null +++ b/superset-frontend/playwright/components/ListView/index.ts @@ -0,0 +1,21 @@ +/** + * 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. + */ + +// 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.locator.check(); + } + + /** + * Unchecks the checkbox (ensures it's unchecked) + */ + async uncheck(): Promise { + await this.locator.uncheck(); + } + + /** + * Toggles the checkbox state + */ + async toggle(): Promise { + await this.locator.click(); + } + + /** + * Checks if the checkbox is checked + */ + async isChecked(): Promise { + return this.locator.isChecked(); + } + + /** + * Checks if the checkbox is visible + */ + async isVisible(): Promise { + return this.locator.isVisible(); + } + + /** + * Checks if the checkbox is enabled + */ + async isEnabled(): Promise { + 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 { + 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 { + // 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 { + 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 { + // 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 { + 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 { + // 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 { + await this.locator.clear(); + } + + /** + * Checks if the select is visible + */ + async isVisible(): Promise { + return this.locator.isVisible(); + } + + /** + * Checks if the select is enabled + */ + async isEnabled(): Promise { + 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 { + 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 { + 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 | `