Copilot commented on code in PR #36684: URL: https://github.com/apache/superset/pull/36684#discussion_r2722992134
########## superset-frontend/playwright/components/modals/ConfirmDialog.ts: ########## @@ -0,0 +1,78 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Page, Locator } from '@playwright/test'; +import { Modal } from '../core/Modal'; + +/** + * Confirm Dialog component for Ant Design Modal.confirm dialogs. + * These are the "OK" / "Cancel" confirmation dialogs used throughout Superset. + * Uses getByRole with name to target specific confirm dialogs when multiple are open. + */ +export class ConfirmDialog extends Modal { + private readonly specificLocator: Locator; + + constructor(page: Page, dialogName = 'Confirm save') { + super(page); + // Use getByRole with specific name to avoid strict mode violations + // when multiple dialogs are open (e.g., Edit Dataset modal + Confirm save dialog) + this.specificLocator = page.getByRole('dialog', { name: dialogName }); + } + + /** + * Override element getter to use specific locator + */ + override get element(): Locator { + return this.specificLocator; + } + + /** + * Clicks the OK button to confirm + * Waits for element to be visible before clicking + */ + async clickOk(): Promise<void> { + // Wait for modal to be visible before clicking + await this.element.waitFor({ state: 'visible' }); + await this.clickFooterButton('OK'); + } + + /** + * Clicks the Cancel button to dismiss + */ + async clickCancel(): Promise<void> { + await this.clickFooterButton('Cancel'); + } + + /** + * Clicks OK if dialog appears within timeout, otherwise silently returns. + * Use when dialog appearance is conditional (e.g., depends on dataset settings). + * @param timeout - How long to wait for dialog (default 2000ms) + */ + async clickOkIfVisible(timeout = 2000): Promise<void> { + try { + await this.element.waitFor({ state: 'visible', timeout }); + await this.clickOk(); + await this.waitForHidden(); + } catch (error) { + if (!(error instanceof Error) || error.name !== 'TimeoutError') { + throw error; + } Review Comment: The error handling pattern here only catches TimeoutError but re-throws all other errors. However, the catch block checks if the error is an instance of Error before accessing the name property. If a non-Error object is thrown (e.g., a string or null), this check will succeed but error.name will be undefined, potentially causing unexpected behavior. Consider adding a more defensive check like: if (error?.name !== 'TimeoutError') throw error; ########## superset-frontend/playwright/components/core/Textarea.ts: ########## @@ -0,0 +1,92 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Locator, Page } from '@playwright/test'; + +/** + * Textarea component for multi-line text input interactions. Review Comment: The Textarea class is missing JSDoc documentation for the class itself, unlike other core component classes (Button, Input, Modal, etc.) in the codebase. Add a JSDoc comment describing the purpose and usage of this component for consistency with the documentation patterns established in other core components. ```suggestion * Playwright helper for interacting with HTML {@link HTMLTextAreaElement | `<textarea>`} elements. * * This component wraps a Playwright {@link Locator} and provides convenience methods for * filling, clearing, and reading the value of a textarea without having to work with * locators directly. * * Typical usage: * ```ts * const textarea = new Textarea(page, 'textarea[name="description"]'); * await textarea.fill('Some multi-line text'); * const value = await textarea.getValue(); * ``` * * You can also construct an instance from the `name` attribute: * ```ts * const textarea = Textarea.fromName(page, 'description'); * await textarea.clear(); * ``` ``` ########## superset-frontend/playwright/components/core/Select.ts: ########## @@ -0,0 +1,180 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Locator, Page } from '@playwright/test'; + +/** + * Ant Design Select component selectors + */ +const SELECT_SELECTORS = { + DROPDOWN: '.ant-select-dropdown', + OPTION: '.ant-select-item-option', + SEARCH_INPUT: '.ant-select-selection-search-input', + CLEAR: '.ant-select-clear', +} as const; + +/** + * Select component for Ant Design Select/Combobox interactions. + */ +export class Select { + readonly page: Page; + private readonly locator: Locator; + + constructor(page: Page, selector: string); + constructor(page: Page, locator: Locator); + constructor(page: Page, selectorOrLocator: string | Locator) { + this.page = page; + if (typeof selectorOrLocator === 'string') { + this.locator = page.locator(selectorOrLocator); + } else { + this.locator = selectorOrLocator; + } + } + + /** + * Creates a Select from a combobox role with the given accessible name + * @param page - The Playwright page + * @param name - The accessible name (aria-label or placeholder text) + */ + static fromRole(page: Page, name: string): Select { + const locator = page.getByRole('combobox', { name }); + return new Select(page, locator); + } + + /** + * Gets the select element locator + */ + get element(): Locator { + return this.locator; + } + + /** + * Opens the dropdown, types to filter, and selects an option. + * Handles cases where the option may not be initially visible in the dropdown. + * Waits for dropdown to close after selection to avoid stale dropdowns. + * @param optionText - The text of the option to select + */ + async selectOption(optionText: string): Promise<void> { + await this.open(); + await this.type(optionText); + await this.clickOption(optionText); + // Wait for dropdown to close to avoid multiple visible dropdowns + await this.waitForDropdownClose(); + } + + /** + * Waits for dropdown to close after selection + * This prevents strict mode violations when multiple selects are used sequentially + */ + private async waitForDropdownClose(): Promise<void> { + // Wait for dropdown to actually close (become hidden) + await this.page + .locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`) + .last() + .waitFor({ state: 'hidden', timeout: 5000 }) + .catch(() => { + // Dropdown may already be closed or never opened + }); + } + + /** + * Opens the dropdown + */ + async open(): Promise<void> { + await this.locator.click(); + } + + /** + * Clicks an option in an already-open dropdown by its text content. + * Uses selector-based approach matching Cypress patterns. + * Handles multiple dropdowns by targeting only visible, non-hidden ones. + * @param optionText - The text of the option to click (partial match for filtered results) + */ + async clickOption(optionText: string): Promise<void> { + // Target visible dropdown (excludes hidden ones via :not(.ant-select-dropdown-hidden)) + // Use .last() in case multiple dropdowns exist - the most recent one is what we want + const dropdown = this.page + .locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`) + .last(); + await dropdown.waitFor({ state: 'visible' }); + + // Find option by text content - use partial match since filtered results may have prefixes + // (e.g., searching for 'main' shows 'examples.main', 'system.main') + // First try exact match, fall back to partial match + const exactOption = dropdown + .locator(SELECT_SELECTORS.OPTION) + .getByText(optionText, { exact: true }); + + if ((await exactOption.count()) > 0) { + await exactOption.click(); + } else { + // Fall back to first option containing the text + const partialOption = dropdown + .locator(SELECT_SELECTORS.OPTION) + .filter({ hasText: optionText }) + .first(); + await partialOption.click(); + } + } + + /** + * Closes the dropdown by pressing Escape + */ + async close(): Promise<void> { + await this.page.keyboard.press('Escape'); + } + + /** + * Types into the select to filter options (assumes dropdown is open) + * @param text - The text to type + */ + async type(text: string): Promise<void> { + // Find the actual search input inside the select component + const searchInput = this.locator.locator(SELECT_SELECTORS.SEARCH_INPUT); + try { + // Wait for search input in case dropdown is still rendering + await searchInput.first().waitFor({ state: 'attached', timeout: 1000 }); + await searchInput.first().fill(text); + } catch { + // Fallback: locator might be the input itself (e.g., from getByRole('combobox')) + await this.locator.fill(text); Review Comment: The type method has a try-catch block that falls back to filling the locator directly if the searchInput is not found. However, the catch block is too broad and will catch any error (including network errors, JavaScript errors, etc.). Consider catching more specific errors or at least checking the error type before falling back to the alternative approach. ```suggestion } catch (error) { // Only fall back when we fail to find/attach the search input (e.g. timeout) if (error instanceof Error && /timeout/i.test(error.message)) { // Fallback: locator might be the input itself (e.g., from getByRole('combobox')) await this.locator.fill(text); } else { // Unexpected error - rethrow so tests fail visibly throw error; } ``` ########## superset-frontend/playwright/components/core/Checkbox.ts: ########## @@ -0,0 +1,82 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Locator, Page } from '@playwright/test'; + +/** + * Checkbox component for checkbox interactions. Review Comment: The Checkbox class is missing JSDoc documentation for the class itself, unlike other core component classes (Button, Input, Modal, etc.) in the codebase. Add a JSDoc comment describing the purpose and usage of this component for consistency with the documentation patterns established in other core components. ```suggestion * Core Checkbox component used in Playwright tests to interact with checkbox * elements in the Superset UI. * * This class wraps a Playwright {@link Locator} pointing to a checkbox input * and provides convenience methods for common interactions such as checking, * unchecking, toggling, and asserting checkbox state and visibility. * * @example * const checkbox = new Checkbox(page, page.locator('input[type="checkbox"]')); * await checkbox.check(); * await expect(await checkbox.isChecked()).toBe(true); * * @param page - The Playwright {@link Page} instance associated with the test. * @param locator - The Playwright {@link Locator} targeting the checkbox element. ``` ########## superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts: ########## @@ -210,17 +230,431 @@ test('should duplicate a dataset with new name', async ({ page }) => { await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible(); await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible(); - // API Verification: Compare original and duplicate datasets - const originalResponseData = await apiGetDataset(page, originalId!); - const originalDataFull = await originalResponseData.json(); - const duplicateResponseData = await apiGetDataset(page, duplicateId); - const duplicateDataFull = await duplicateResponseData.json(); + // API Verification: Fetch both datasets via detail API for consistent comparison + // (list API may return undefined for fields that detail API returns as null) + const [originalDetailRes, duplicateDetailRes] = await Promise.all([ + apiGetDataset(page, original!.id), + apiGetDataset(page, duplicateId), + ]); + const originalDetail = (await originalDetailRes.json()).result; + const duplicateDetail = (await duplicateDetailRes.json()).result; // Verify key properties were copied correctly - expect(duplicateDataFull.result.sql).toBe(originalDataFull.result.sql); - expect(duplicateDataFull.result.database.id).toBe( - originalDataFull.result.database.id, - ); + expect(duplicateDetail.sql).toBe(originalDetail.sql); + expect(duplicateDetail.database.id).toBe(originalDetail.database.id); + expect(duplicateDetail.schema).toBe(originalDetail.schema); // Name should be different (the duplicate name) - expect(duplicateDataFull.result.table_name).toBe(duplicateName); + expect(duplicateDetail.table_name).toBe(duplicateName); +}); + +test('should export a dataset as a zip file', async ({ + page, + datasetListPage, +}) => { + // Use existing example dataset + const datasetName = 'members_channels_2'; + const dataset = await getDatasetByName(page, datasetName); + expect(dataset).not.toBeNull(); + + // Verify dataset is visible in list + await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible(); + + // Set up API response intercept for export endpoint + // Note: We intercept the API response instead of relying on download events because + // Superset uses blob downloads (createObjectURL) which don't trigger Playwright's + // download event consistently, especially in app-prefix configurations. + const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT); + + // Click export action button + await datasetListPage.clickExportAction(datasetName); + + // Wait for export API response and validate zip contents + const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]); + await expectValidExportZip(exportResponse, { checkContentDisposition: true }); +}); + +test('should export multiple datasets via bulk select action', async ({ + page, + datasetListPage, + testAssets, +}) => { + // Create 2 throwaway datasets for bulk export + const [dataset1, dataset2] = await Promise.all([ + createTestDataset(page, testAssets, test.info(), { + prefix: 'bulk_export_1', + }), + createTestDataset(page, testAssets, test.info(), { + prefix: 'bulk_export_2', + }), + ]); + + // Refresh to see new datasets + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + + // Verify both datasets are visible in list + await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible(); + await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible(); + + // Enable bulk select mode + await datasetListPage.clickBulkSelectButton(); + + // Select both datasets + await datasetListPage.selectDatasetCheckbox(dataset1.name); + await datasetListPage.selectDatasetCheckbox(dataset2.name); + + // Set up API response intercept for export endpoint + const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT); + + // Click bulk export action + await datasetListPage.clickBulkAction('Export'); + + // Wait for export API response and validate zip contains multiple datasets + const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]); + await expectValidExportZip(exportResponse, { minDatasetCount: 2 }); +}); + +test('should edit dataset name via modal', async ({ + page, + datasetListPage, + testAssets, +}) => { + // Create throwaway dataset for editing + const { id: datasetId, name: datasetName } = await createTestDataset( + page, + testAssets, + test.info(), + { prefix: 'test_edit' }, + ); + + // Refresh to see new dataset + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + + // Verify dataset is visible in list + await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible(); + + // Click edit action to open modal + await datasetListPage.clickEditAction(datasetName); + + // Wait for edit modal to be ready + const editModal = new EditDatasetModal(page); + await editModal.waitForReady(); + + // Enable edit mode by clicking the lock icon + await editModal.enableEditMode(); + + // Edit the dataset name + const newName = `test_renamed_${Date.now()}`; + await editModal.fillName(newName); + + // Set up response intercept for save + const saveResponsePromise = waitForPut( + page, + `${ENDPOINTS.DATASET}${datasetId}`, + ); + + // Click Save button + await editModal.clickSave(); + + // Handle the "Confirm save" dialog that appears for datasets with sync columns enabled + const confirmDialog = new ConfirmDialog(page); + await confirmDialog.clickOk(); + + // Wait for save to complete and verify success + expectStatusOneOf(await saveResponsePromise, [200, 201]); + + // Modal should close + await editModal.waitForHidden(); + + // Verify success toast appears + const toast = new Toast(page); + await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 }); + + // Verify via API that name was saved + const updatedDatasetRes = await apiGetDataset(page, datasetId); + const updatedDataset = (await updatedDatasetRes.json()).result; + expect(updatedDataset.table_name).toBe(newName); +}); + +test('should bulk delete multiple datasets', async ({ + page, + datasetListPage, + testAssets, +}) => { + // Create 2 throwaway datasets for bulk delete + const [dataset1, dataset2] = await Promise.all([ + createTestDataset(page, testAssets, test.info(), { + prefix: 'bulk_delete_1', + }), + createTestDataset(page, testAssets, test.info(), { + prefix: 'bulk_delete_2', + }), + ]); + + // Refresh to see new datasets + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + + // Verify both datasets are visible in list + await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible(); + await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible(); + + // Enable bulk select mode + await datasetListPage.clickBulkSelectButton(); + + // Select both datasets + await datasetListPage.selectDatasetCheckbox(dataset1.name); + await datasetListPage.selectDatasetCheckbox(dataset2.name); + + // Click bulk delete action + await datasetListPage.clickBulkAction('Delete'); + + // Delete confirmation modal should appear + const deleteModal = new DeleteConfirmationModal(page); + await deleteModal.waitForVisible(); + + // Type "DELETE" to confirm + await deleteModal.fillConfirmationInput('DELETE'); + + // Click the Delete button + await deleteModal.clickDelete(); + + // Modal should close + await deleteModal.waitForHidden(); + + // Verify success toast appears + const toast = new Toast(page); + await expect(toast.getSuccess()).toBeVisible(); + + // Verify both datasets are removed from list + await expect(datasetListPage.getDatasetRow(dataset1.name)).not.toBeVisible(); + await expect(datasetListPage.getDatasetRow(dataset2.name)).not.toBeVisible(); + + // Verify via API that datasets no longer exist (404) + // Use polling since deletes may be async + await expect + .poll(async () => { + const response = await apiGetDataset(page, dataset1.id, { + failOnStatusCode: false, + }); + return response.status(); + }) + .toBe(404); + await expect + .poll(async () => { + const response = await apiGetDataset(page, dataset2.id, { + failOnStatusCode: false, + }); + return response.status(); + }) + .toBe(404); +}); + +// Import test uses a fixed dataset name from the zip fixture. +// Uses test.describe only because Playwright's serial mode API requires it - +// this prevents race conditions when parallel workers import the same fixture. +// (Deviation from "avoid describe" guideline is necessary for functional reasons) +test.describe('import dataset', () => { + test.describe.configure({ mode: 'serial' }); + test('should import a dataset from a zip file', async ({ + page, + datasetListPage, + testAssets, + }) => { + // Dataset name from fixture (test_netflix_1768502050965) + // Note: Fixture contains a Google Sheets dataset - test will skip if gsheets connector unavailable + const importedDatasetName = 'test_netflix_1768502050965'; + const fixturePath = path.resolve( + __dirname, + '../../../fixtures/dataset_export.zip', + ); + + // Cleanup: Delete any existing dataset with the same name from previous runs + const existingDataset = await getDatasetByName(page, importedDatasetName); + if (existingDataset) { + await apiDeleteDataset(page, existingDataset.id, { + failOnStatusCode: false, + }); + } + + // Click the import button + await datasetListPage.clickImportButton(); + + // Wait for import modal to be ready + const importModal = new ImportDatasetModal(page); + await importModal.waitForReady(); + + // Upload the fixture zip file + await importModal.uploadFile(fixturePath); + + // Set up response intercept to catch the import POST + // Use pathMatch to avoid false matches if URL lacks trailing slash + let importResponsePromise = waitForPost(page, ENDPOINTS.DATASET_IMPORT, { + pathMatch: true, + }); + + // Click Import button + await importModal.clickImport(); + + // Wait for first import response + let importResponse = await importResponsePromise; + + // Handle overwrite confirmation if dataset already exists + // First response may be 409/422 indicating overwrite is required - this is expected + const overwriteInput = importModal.getOverwriteInput(); + await overwriteInput + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(error => { + // Only ignore TimeoutError (input not visible); re-throw other errors + if (!(error instanceof Error) || error.name !== 'TimeoutError') { + throw error; + } + }); + + if (await overwriteInput.isVisible()) { + // Set up new intercept for the actual import after overwrite confirmation + importResponsePromise = waitForPost(page, ENDPOINTS.DATASET_IMPORT, { + pathMatch: true, + }); + await importModal.fillOverwriteConfirmation(); + await importModal.clickImport(); + // Wait for the second (final) import response + importResponse = await importResponsePromise; + } + + // Check final import response for gsheets connector errors + if (!importResponse.ok()) { + const errorBody = await importResponse.json().catch(() => ({})); + const errorText = JSON.stringify(errorBody); + // Skip test if gsheets connector not installed + if ( + errorText.includes('gsheets') || + errorText.includes('No such DB engine') || + errorText.includes('Could not load database driver') + ) { + await test.info().attach('skip-reason', { + body: `Import failed due to missing gsheets connector: ${errorText}`, + contentType: 'text/plain', + }); + test.skip(); + return; + } + // Re-throw other errors + throw new Error(`Import failed: ${errorText}`); + } + + // Modal should close on success + await importModal.waitForHidden({ timeout: 30000 }); + + // Verify success toast appears + const toast = new Toast(page); + await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 }); + + // Refresh the page to see the imported dataset + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + + // Verify dataset appears in list + await expect( + datasetListPage.getDatasetRow(importedDatasetName), + ).toBeVisible(); + + // Get dataset ID for cleanup + const importedDataset = await getDatasetByName(page, importedDatasetName); + expect(importedDataset).not.toBeNull(); + testAssets.trackDataset(importedDataset!.id); + }); +}); + +test('should edit column date format via modal', async ({ + page, + datasetListPage, + testAssets, +}) => { + // Create virtual dataset with a date column for testing + // Using SQL to create a dataset with 'ds' column avoids duplication issues + const datasetName = `test_date_format_${Date.now()}_${test.info().parallelIndex}`; + const baseDataset = await getDatasetByName(page, 'members_channels_2'); + expect(baseDataset, 'members_channels_2 dataset must exist').not.toBeNull(); + + const createResponse = await apiPostVirtualDataset(page, { + database: baseDataset!.database.id, + schema: baseDataset!.schema ?? '', Review Comment: The schema field in VirtualDatasetCreatePayload is defined as 'string | null', but in the apiPostVirtualDataset call on line 583, it uses 'baseDataset!.schema ?? '''. This means if schema is null, it will be converted to an empty string. However, the type definition allows null. Consider either: (1) passing null directly when schema is null to match the type signature, or (2) documenting why an empty string is preferred over null for the schema field. ```suggestion schema: baseDataset!.schema ?? null, ``` ########## superset-frontend/playwright/helpers/fixtures/testAssets.ts: ########## @@ -0,0 +1,68 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { test as base } from '@playwright/test'; +import { apiDeleteDataset } from '../api/dataset'; +import { apiDeleteDatabase } from '../api/database'; + +/** + * Test asset tracker for automatic cleanup after each test. + * Inspired by Cypress's cleanDashboards/cleanCharts pattern. + */ +export interface TestAssets { + trackDataset(id: number): void; + trackDatabase(id: number): void; +} + +export const test = base.extend<{ testAssets: TestAssets }>({ + testAssets: async ({ page }, use) => { + // Use Set to de-dupe IDs (same resource may be tracked multiple times) + const datasetIds = new Set<number>(); + const databaseIds = new Set<number>(); + + await use({ + trackDataset: id => datasetIds.add(id), + trackDatabase: id => databaseIds.add(id), Review Comment: The testAssets fixture uses Set to de-duplicate IDs (line 36-37), which is good. However, if a test tracks the same ID multiple times (e.g., due to a bug or retry logic), the cleanup will only attempt deletion once. While this is generally fine, consider logging when duplicate IDs are tracked to help identify potential test issues during development. ```suggestion trackDataset: id => { if (datasetIds.has(id)) { console.warn( `[testAssets] Dataset ID ${id} is being tracked multiple times in the same test.`, ); } datasetIds.add(id); }, trackDatabase: id => { if (databaseIds.has(id)) { console.warn( `[testAssets] Database ID ${id} is being tracked multiple times in the same test.`, ); } databaseIds.add(id); }, ``` ########## superset-frontend/playwright.config.ts: ########## @@ -117,10 +120,17 @@ export default defineConfig({ // Web server setup - disabled in CI (Flask started separately in workflow) webServer: process.env.CI ? undefined - : { - command: 'curl -f http://localhost:8088/health', - url: 'http://localhost:8088/health', - reuseExistingServer: true, - timeout: 5000, - }, + : (() => { + // Support custom base URL (e.g., http://localhost:9012/app/prefix/) + const baseUrl = + process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088'; + // Extract origin (scheme + host + port) for health check + const healthUrl = new URL('health', baseUrl).href; + return { + command: `curl -f ${healthUrl}`, + url: healthUrl, + reuseExistingServer: true, + timeout: 5000, + }; + })(), Review Comment: The webServer configuration now extracts the base URL to derive the health check URL (lines 124-129). However, if PLAYWRIGHT_BASE_URL is set to a URL with a path prefix (e.g., 'http://localhost:9012/app/'), the health check will be sent to 'http://localhost:9012/app/health' instead of 'http://localhost:9012/health'. Verify that the health endpoint respects path prefixes, or extract the origin (scheme + host + port) differently to ensure health checks work correctly with prefixed URLs. ########## superset-frontend/playwright/components/modals/EditDatasetModal.ts: ########## @@ -0,0 +1,184 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Locator, Page } from '@playwright/test'; +import { Input, Modal, Tabs, AceEditor } from '../core'; + +/** + * Edit Dataset Modal component (DatasourceModal). + * Used for editing dataset properties like description, metrics, columns, etc. + * Uses specific dialog name to avoid strict mode violations when multiple dialogs are open. + */ +export class EditDatasetModal extends Modal { + private static readonly SELECTORS = { + NAME_INPUT: '[data-test="inline-name"]', + LOCK_ICON: '[data-test="lock"]', + UNLOCK_ICON: '[data-test="unlock"]', + }; + + private readonly tabs: Tabs; + private readonly specificLocator: Locator; + + constructor(page: Page) { + super(page); + // Use getByRole with specific name to target Edit Dataset dialog + // The dialog has aria-labelledby that resolves to "edit Edit Dataset" + this.specificLocator = page.getByRole('dialog', { name: /edit.*dataset/i }); + // Scope tabs to modal's tablist to avoid matching tablists elsewhere on page + this.tabs = new Tabs(page, this.specificLocator.getByRole('tablist')); + } + + /** + * Override element getter to use specific locator + */ + override get element(): Locator { + return this.specificLocator; + } + + /** + * Click the Save button to save changes + */ + async clickSave(): Promise<void> { + await this.clickFooterButton('Save'); + } + + /** + * Click the Cancel button to discard changes + */ + async clickCancel(): Promise<void> { + await this.clickFooterButton('Cancel'); + } + + /** + * Click the lock icon to enable edit mode + * The modal starts in read-only mode and requires clicking the lock to edit + */ + async enableEditMode(): Promise<void> { + const lockButton = this.body.locator(EditDatasetModal.SELECTORS.LOCK_ICON); + await lockButton.click(); + } + + /** + * Gets the dataset name input component + */ + private get nameInput(): Input { + return new Input( + this.page, + this.body.locator(EditDatasetModal.SELECTORS.NAME_INPUT), + ); + } + + /** + * Fill in the dataset name field + * Note: Call enableEditMode() first if the modal is in read-only mode + * @param name - The new dataset name + */ + async fillName(name: string): Promise<void> { + await this.nameInput.fill(name); + } + + /** + * Navigate to a specific tab in the modal + * @param tabName - The name of the tab (e.g., 'Source', 'Metrics', 'Columns') + */ + async clickTab(tabName: string): Promise<void> { + await this.tabs.clickTab(tabName); + } + + /** + * Navigate to the Settings tab + */ + async clickSettingsTab(): Promise<void> { + await this.tabs.clickTab('Settings'); + } + + /** + * Navigate to the Columns tab. + * Uses regex to avoid matching "Calculated columns" tab, scoped to modal. + */ + async clickColumnsTab(): Promise<void> { + // Use regex starting with "Columns" to avoid matching "Calculated columns" + // Scope to modal element to avoid matching tabs elsewhere on page + await this.element.getByRole('tab', { name: /^Columns/ }).click(); + } + + /** + * Gets the description Ace Editor component (Settings tab). + * The Description button and ace-editor are in the same form item. + */ + private get descriptionEditor(): AceEditor { + // Use tabpanel role with name "Settings" for more reliable lookup + const settingsPanel = this.element.getByRole('tabpanel', { name: 'Settings' }); + // Find the form item that contains the Description button + const descriptionFormItem = settingsPanel + .locator('.ant-form-item') + .filter({ + has: this.page.getByRole('button', { name: 'Description', exact: true }), + }) + .first(); + // The ace-editor has class .ace_editor within the form item + const editorElement = descriptionFormItem.locator('.ace_editor'); + return new AceEditor(this.page, editorElement); + } + + /** + * Fill the dataset description field (Settings tab). + * @param description - The description text to set + */ + async fillDescription(description: string): Promise<void> { + await this.descriptionEditor.setText(description); + } + + /** + * Expand a column row by column name. + * Uses exact cell match to avoid false positives with short names like "ds". + * @param columnName - The name of the column to expand + * @returns The row locator for scoped selector access + */ + async expandColumn(columnName: string): Promise<Locator> { + // Find cell with exact column name text, then derive row from that cell + const cell = this.body.getByRole('cell', { name: columnName, exact: true }); + const row = cell.locator('xpath=ancestor::tr[1]'); Review Comment: The EditDatasetModal.expandColumn method uses an XPath expression 'xpath=ancestor::tr[1]' to find the row element from a cell (line 157). XPath selectors are generally more fragile than modern Playwright locators and can break if the DOM structure changes. Consider using a more robust selector strategy, such as cell.locator('..').locator('xpath=ancestor::tr[1]') or refactoring to use getByRole with table row relationships. ```suggestion const row = this.body.getByRole('row', { has: cell }); ``` ########## superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts: ########## @@ -210,17 +230,431 @@ test('should duplicate a dataset with new name', async ({ page }) => { await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible(); await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible(); - // API Verification: Compare original and duplicate datasets - const originalResponseData = await apiGetDataset(page, originalId!); - const originalDataFull = await originalResponseData.json(); - const duplicateResponseData = await apiGetDataset(page, duplicateId); - const duplicateDataFull = await duplicateResponseData.json(); + // API Verification: Fetch both datasets via detail API for consistent comparison + // (list API may return undefined for fields that detail API returns as null) + const [originalDetailRes, duplicateDetailRes] = await Promise.all([ + apiGetDataset(page, original!.id), + apiGetDataset(page, duplicateId), + ]); + const originalDetail = (await originalDetailRes.json()).result; + const duplicateDetail = (await duplicateDetailRes.json()).result; // Verify key properties were copied correctly - expect(duplicateDataFull.result.sql).toBe(originalDataFull.result.sql); - expect(duplicateDataFull.result.database.id).toBe( - originalDataFull.result.database.id, - ); + expect(duplicateDetail.sql).toBe(originalDetail.sql); + expect(duplicateDetail.database.id).toBe(originalDetail.database.id); + expect(duplicateDetail.schema).toBe(originalDetail.schema); // Name should be different (the duplicate name) - expect(duplicateDataFull.result.table_name).toBe(duplicateName); + expect(duplicateDetail.table_name).toBe(duplicateName); +}); + +test('should export a dataset as a zip file', async ({ + page, + datasetListPage, +}) => { + // Use existing example dataset + const datasetName = 'members_channels_2'; + const dataset = await getDatasetByName(page, datasetName); + expect(dataset).not.toBeNull(); + + // Verify dataset is visible in list + await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible(); + + // Set up API response intercept for export endpoint + // Note: We intercept the API response instead of relying on download events because + // Superset uses blob downloads (createObjectURL) which don't trigger Playwright's + // download event consistently, especially in app-prefix configurations. + const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT); + + // Click export action button + await datasetListPage.clickExportAction(datasetName); + + // Wait for export API response and validate zip contents + const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]); + await expectValidExportZip(exportResponse, { checkContentDisposition: true }); +}); + +test('should export multiple datasets via bulk select action', async ({ + page, + datasetListPage, + testAssets, +}) => { + // Create 2 throwaway datasets for bulk export + const [dataset1, dataset2] = await Promise.all([ + createTestDataset(page, testAssets, test.info(), { + prefix: 'bulk_export_1', + }), + createTestDataset(page, testAssets, test.info(), { + prefix: 'bulk_export_2', + }), + ]); + + // Refresh to see new datasets + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + + // Verify both datasets are visible in list + await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible(); + await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible(); + + // Enable bulk select mode + await datasetListPage.clickBulkSelectButton(); + + // Select both datasets + await datasetListPage.selectDatasetCheckbox(dataset1.name); + await datasetListPage.selectDatasetCheckbox(dataset2.name); + + // Set up API response intercept for export endpoint + const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT); + + // Click bulk export action + await datasetListPage.clickBulkAction('Export'); + + // Wait for export API response and validate zip contains multiple datasets + const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]); + await expectValidExportZip(exportResponse, { minDatasetCount: 2 }); +}); + +test('should edit dataset name via modal', async ({ + page, + datasetListPage, + testAssets, +}) => { + // Create throwaway dataset for editing + const { id: datasetId, name: datasetName } = await createTestDataset( + page, + testAssets, + test.info(), + { prefix: 'test_edit' }, + ); + + // Refresh to see new dataset + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + + // Verify dataset is visible in list + await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible(); + + // Click edit action to open modal + await datasetListPage.clickEditAction(datasetName); + + // Wait for edit modal to be ready + const editModal = new EditDatasetModal(page); + await editModal.waitForReady(); + + // Enable edit mode by clicking the lock icon + await editModal.enableEditMode(); + + // Edit the dataset name + const newName = `test_renamed_${Date.now()}`; + await editModal.fillName(newName); + + // Set up response intercept for save + const saveResponsePromise = waitForPut( + page, + `${ENDPOINTS.DATASET}${datasetId}`, + ); + + // Click Save button + await editModal.clickSave(); + + // Handle the "Confirm save" dialog that appears for datasets with sync columns enabled + const confirmDialog = new ConfirmDialog(page); + await confirmDialog.clickOk(); + + // Wait for save to complete and verify success + expectStatusOneOf(await saveResponsePromise, [200, 201]); + + // Modal should close + await editModal.waitForHidden(); + + // Verify success toast appears + const toast = new Toast(page); + await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 }); + + // Verify via API that name was saved + const updatedDatasetRes = await apiGetDataset(page, datasetId); + const updatedDataset = (await updatedDatasetRes.json()).result; + expect(updatedDataset.table_name).toBe(newName); +}); + +test('should bulk delete multiple datasets', async ({ + page, + datasetListPage, + testAssets, +}) => { + // Create 2 throwaway datasets for bulk delete + const [dataset1, dataset2] = await Promise.all([ + createTestDataset(page, testAssets, test.info(), { + prefix: 'bulk_delete_1', + }), + createTestDataset(page, testAssets, test.info(), { + prefix: 'bulk_delete_2', + }), + ]); + + // Refresh to see new datasets + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + + // Verify both datasets are visible in list + await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible(); + await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible(); + + // Enable bulk select mode + await datasetListPage.clickBulkSelectButton(); + + // Select both datasets + await datasetListPage.selectDatasetCheckbox(dataset1.name); + await datasetListPage.selectDatasetCheckbox(dataset2.name); + + // Click bulk delete action + await datasetListPage.clickBulkAction('Delete'); + + // Delete confirmation modal should appear + const deleteModal = new DeleteConfirmationModal(page); + await deleteModal.waitForVisible(); + + // Type "DELETE" to confirm + await deleteModal.fillConfirmationInput('DELETE'); + + // Click the Delete button + await deleteModal.clickDelete(); + + // Modal should close + await deleteModal.waitForHidden(); + + // Verify success toast appears + const toast = new Toast(page); + await expect(toast.getSuccess()).toBeVisible(); + + // Verify both datasets are removed from list + await expect(datasetListPage.getDatasetRow(dataset1.name)).not.toBeVisible(); + await expect(datasetListPage.getDatasetRow(dataset2.name)).not.toBeVisible(); + + // Verify via API that datasets no longer exist (404) + // Use polling since deletes may be async + await expect + .poll(async () => { + const response = await apiGetDataset(page, dataset1.id, { + failOnStatusCode: false, + }); + return response.status(); + }) + .toBe(404); + await expect + .poll(async () => { + const response = await apiGetDataset(page, dataset2.id, { + failOnStatusCode: false, + }); + return response.status(); + }) Review Comment: The bulk delete test uses polling with expect.poll() to verify datasets are deleted (lines 436-451). However, there's no maximum wait time specified, so it will use Playwright's default timeout. If the delete operation takes longer than expected or fails silently, the test could hang. Consider adding an explicit timeout to the poll operation to make test failures more predictable. ```suggestion .poll( async () => { const response = await apiGetDataset(page, dataset1.id, { failOnStatusCode: false, }); return response.status(); }, { timeout: 30_000 }, ) .toBe(404); await expect .poll( async () => { const response = await apiGetDataset(page, dataset2.id, { failOnStatusCode: false, }); return response.status(); }, { timeout: 30_000 }, ) ``` ########## superset-frontend/playwright/components/core/Select.ts: ########## @@ -0,0 +1,180 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Locator, Page } from '@playwright/test'; + +/** + * Ant Design Select component selectors + */ +const SELECT_SELECTORS = { + DROPDOWN: '.ant-select-dropdown', + OPTION: '.ant-select-item-option', + SEARCH_INPUT: '.ant-select-selection-search-input', + CLEAR: '.ant-select-clear', +} as const; + +/** + * Select component for Ant Design Select/Combobox interactions. + */ +export class Select { + readonly page: Page; + private readonly locator: Locator; + + constructor(page: Page, selector: string); + constructor(page: Page, locator: Locator); + constructor(page: Page, selectorOrLocator: string | Locator) { + this.page = page; + if (typeof selectorOrLocator === 'string') { + this.locator = page.locator(selectorOrLocator); + } else { + this.locator = selectorOrLocator; + } + } + + /** + * Creates a Select from a combobox role with the given accessible name + * @param page - The Playwright page + * @param name - The accessible name (aria-label or placeholder text) + */ + static fromRole(page: Page, name: string): Select { + const locator = page.getByRole('combobox', { name }); + return new Select(page, locator); + } + + /** + * Gets the select element locator + */ + get element(): Locator { + return this.locator; + } + + /** + * Opens the dropdown, types to filter, and selects an option. + * Handles cases where the option may not be initially visible in the dropdown. + * Waits for dropdown to close after selection to avoid stale dropdowns. + * @param optionText - The text of the option to select + */ + async selectOption(optionText: string): Promise<void> { + await this.open(); + await this.type(optionText); + await this.clickOption(optionText); + // Wait for dropdown to close to avoid multiple visible dropdowns + await this.waitForDropdownClose(); + } + + /** + * Waits for dropdown to close after selection + * This prevents strict mode violations when multiple selects are used sequentially + */ + private async waitForDropdownClose(): Promise<void> { + // Wait for dropdown to actually close (become hidden) + await this.page + .locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`) + .last() + .waitFor({ state: 'hidden', timeout: 5000 }) + .catch(() => { + // Dropdown may already be closed or never opened + }); Review Comment: The waitForDropdownClose method catches all errors and silently ignores them with an empty catch block. This could hide legitimate errors unrelated to the dropdown already being closed. Consider being more specific about which errors to ignore, or at least log warnings for unexpected errors. For example, you could check if the error is a TimeoutError before ignoring it. ########## superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts: ########## @@ -17,76 +17,90 @@ * under the License. */ -import { test, expect } from '@playwright/test'; +import { + test as testWithAssets, + expect, +} from '../../../helpers/fixtures/testAssets'; +import type { Response } from '@playwright/test'; +import path from 'path'; +import * as unzipper from 'unzipper'; import { DatasetListPage } from '../../../pages/DatasetListPage'; import { ExplorePage } from '../../../pages/ExplorePage'; +import { ConfirmDialog } from '../../../components/modals/ConfirmDialog'; import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal'; +import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal'; import { DuplicateDatasetModal } from '../../../components/modals/DuplicateDatasetModal'; +import { EditDatasetModal } from '../../../components/modals/EditDatasetModal'; import { Toast } from '../../../components/core/Toast'; import { apiDeleteDataset, apiGetDataset, + apiPostVirtualDataset, getDatasetByName, - createTestVirtualDataset, ENDPOINTS, } from '../../../helpers/api/dataset'; +import { createTestDataset } from './dataset-test-helpers'; +import { + waitForGet, + waitForPost, + waitForPut, +} from '../../../helpers/api/intercepts'; +import { expectStatusOneOf } from '../../../helpers/api/assertions'; /** - * Test data constants - * PHYSICAL_DATASET: A physical dataset from examples (for navigation tests) - * Tests that need virtual datasets (duplicate/delete) create their own hermetic data + * Extend testWithAssets with datasetListPage navigation (beforeEach equivalent). */ -const TEST_DATASETS = { - /** Physical dataset for basic navigation tests */ - PHYSICAL_DATASET: 'birth_names', -} as const; +const test = testWithAssets.extend<{ datasetListPage: DatasetListPage }>({ + datasetListPage: async ({ page }, use) => { + const datasetListPage = new DatasetListPage(page); + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + await use(datasetListPage); + }, +}); /** - * Dataset List E2E Tests - * - * Uses flat test() structure per project convention (matches login.spec.ts). - * Shared state and hooks are at file scope. + * Helper to validate an export zip response. + * Verifies headers, parses zip contents, and validates expected structure. */ +async function expectValidExportZip( + response: Response, + options: { minDatasetCount?: number; checkContentDisposition?: boolean } = {}, +): Promise<void> { + const { minDatasetCount = 1, checkContentDisposition = false } = options; Review Comment: The parameter name 'checkContentDisposition' is a boolean flag that controls whether to check content-disposition header. In the function implementation (line 75-77), when this flag is true, the header is checked. However, the default value is false (line 70), meaning by default this validation is skipped. Consider renaming to 'validateContentDisposition' or 'verifyContentDisposition' for clarity, or document why this check is optional by default. ########## superset-frontend/playwright/tests/experimental/dataset/create-dataset.spec.ts: ########## @@ -0,0 +1,211 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { test, expect } from '../../../helpers/fixtures/testAssets'; +import type { TestAssets } from '../../../helpers/fixtures/testAssets'; +import type { Page, TestInfo } from '@playwright/test'; +import { ExplorePage } from '../../../pages/ExplorePage'; +import { CreateDatasetPage } from '../../../pages/CreateDatasetPage'; +import { DatasetListPage } from '../../../pages/DatasetListPage'; +import { ChartCreationPage } from '../../../pages/ChartCreationPage'; +import { ENDPOINTS } from '../../../helpers/api/dataset'; +import { waitForPost } from '../../../helpers/api/intercepts'; +import { expectStatusOneOf } from '../../../helpers/api/assertions'; +import { apiPostDatabase } from '../../../helpers/api/database'; + +interface GsheetsSetupResult { + sheetName: string; + dbName: string; + createDatasetPage: CreateDatasetPage; +} + +/** + * Sets up gsheets database and navigates to create dataset page. + * Skips test if gsheets connector unavailable. + * @param testInfo - Test info for parallelIndex to avoid name collisions in parallel runs + * @returns Setup result with names and page object, or null if test.skip() was called + */ +async function setupGsheetsDataset( + page: Page, + testAssets: TestAssets, + testInfo: TestInfo, +): Promise<GsheetsSetupResult | null> { + // Public Google Sheet for testing (published to web, no auth required) + // This is a Netflix dataset that is publicly accessible via Google Visualization API + const sheetUrl = + 'https://docs.google.com/spreadsheets/d/19XNqckHGKGGPh83JGFdFGP4Bw9gdXeujq5EoIGwttdM/edit#gid=347941303'; + // Include parallelIndex to avoid collisions when tests run in parallel + const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`; + const sheetName = `test_netflix_${uniqueSuffix}`; + const dbName = `test_gsheets_db_${uniqueSuffix}`; + + // Create a Google Sheets database via API + // The catalog must be in `extra` as JSON with engine_params.catalog format + const catalogDict = { [sheetName]: sheetUrl }; + const createDbRes = await apiPostDatabase(page, { + database_name: dbName, + engine: 'gsheets', + sqlalchemy_uri: 'gsheets://', + configuration_method: 'dynamic_form', + expose_in_sqllab: true, + extra: JSON.stringify({ + engine_params: { + catalog: catalogDict, + }, + }), + }); + + // Check if gsheets connector is available + if (!createDbRes.ok()) { + const errorBody = await createDbRes.json(); + const errorText = JSON.stringify(errorBody); + // Skip test if gsheets connector not installed + if ( + errorText.includes('gsheets') || + errorText.includes('No such DB engine') + ) { + await test.info().attach('skip-reason', { + body: `Google Sheets connector unavailable: ${errorText}`, + contentType: 'text/plain', + }); + test.skip(); + return null; + } + throw new Error(`Failed to create gsheets database: ${errorText}`); + } + + const createDbBody = await createDbRes.json(); + const dbId = createDbBody.result?.id ?? createDbBody.id; + if (!dbId) { + throw new Error('Database creation did not return an ID'); + } + testAssets.trackDatabase(dbId); + + // Navigate to create dataset page + const createDatasetPage = new CreateDatasetPage(page); + await createDatasetPage.goto(); + await createDatasetPage.waitForPageLoad(); + + // Select the Google Sheets database + await createDatasetPage.selectDatabase(dbName); + + // Try to select the sheet - if not found due to timeout, skip + try { + await createDatasetPage.selectTable(sheetName); + } catch (error) { + // Only skip on TimeoutError (sheet not loaded); re-throw everything else + if (!(error instanceof Error) || error.name !== 'TimeoutError') { Review Comment: The error handling pattern here only catches TimeoutError but re-throws all other errors. However, the catch block checks if the error is an instance of Error before accessing the name property. If a non-Error object is thrown (e.g., a string or null), this check will succeed but error.name will be undefined, potentially causing unexpected behavior. Consider adding a more defensive check like: if (error?.name !== 'TimeoutError') throw error; ```suggestion if ((error as { name?: string } | null | undefined)?.name !== 'TimeoutError') { ``` ########## superset-frontend/playwright/tests/experimental/dataset/create-dataset.spec.ts: ########## @@ -0,0 +1,211 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { test, expect } from '../../../helpers/fixtures/testAssets'; +import type { TestAssets } from '../../../helpers/fixtures/testAssets'; +import type { Page, TestInfo } from '@playwright/test'; +import { ExplorePage } from '../../../pages/ExplorePage'; +import { CreateDatasetPage } from '../../../pages/CreateDatasetPage'; +import { DatasetListPage } from '../../../pages/DatasetListPage'; +import { ChartCreationPage } from '../../../pages/ChartCreationPage'; +import { ENDPOINTS } from '../../../helpers/api/dataset'; +import { waitForPost } from '../../../helpers/api/intercepts'; +import { expectStatusOneOf } from '../../../helpers/api/assertions'; +import { apiPostDatabase } from '../../../helpers/api/database'; + +interface GsheetsSetupResult { + sheetName: string; + dbName: string; + createDatasetPage: CreateDatasetPage; +} + +/** + * Sets up gsheets database and navigates to create dataset page. + * Skips test if gsheets connector unavailable. + * @param testInfo - Test info for parallelIndex to avoid name collisions in parallel runs + * @returns Setup result with names and page object, or null if test.skip() was called + */ +async function setupGsheetsDataset( + page: Page, + testAssets: TestAssets, + testInfo: TestInfo, +): Promise<GsheetsSetupResult | null> { + // Public Google Sheet for testing (published to web, no auth required) + // This is a Netflix dataset that is publicly accessible via Google Visualization API Review Comment: The code uses a hardcoded public Google Sheets URL for testing. While the comment indicates this is a "public" sheet, consider documenting the ownership and access controls of this external resource. If this Google Sheet is deleted or made private in the future, these tests will fail. Consider adding a comment about who owns/maintains this sheet or creating a test-specific sheet owned by the project. ```suggestion // Public Google Sheet for testing (published to web, no auth required). // This is a Netflix dataset that is publicly accessible via the Google Visualization API. // NOTE: This sheet is hosted on an external Google account and is not created by the test itself. // If this sheet is deleted, its ID changes, or its sharing settings are restricted, // these tests will start failing when they attempt to create a database pointing at it. // In that case, create or select a new publicly readable test sheet, update `sheetUrl` // to use its URL, and update this comment to describe who owns/maintains that sheet // and the expected access controls (e.g., "anyone with the link can view"). ``` ########## superset-frontend/playwright/pages/CreateDatasetPage.ts: ########## @@ -0,0 +1,138 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Page } from '@playwright/test'; +import { Button, Select } from '../components/core'; + +/** + * Create Dataset Page object for the dataset creation wizard. + */ +export class CreateDatasetPage { + readonly page: Page; + + /** + * Data-test selectors for the create dataset form elements. + * Using data-test attributes avoids strict mode violations with multiple selects. + */ + private static readonly SELECTORS = { + DATABASE: '[data-test="select-database"]', + SCHEMA: '[data-test="Select schema or type to search schemas"]', + TABLE: '[data-test="Select table or type to search tables"]', + } as const; + + constructor(page: Page) { + this.page = page; + } + + /** + * Gets the database selector using data-test attribute + */ + getDatabaseSelect(): Select { + return new Select(this.page, CreateDatasetPage.SELECTORS.DATABASE); + } + + /** + * Gets the schema selector using data-test attribute + */ + getSchemaSelect(): Select { + return new Select(this.page, CreateDatasetPage.SELECTORS.SCHEMA); + } + + /** + * Gets the table selector using data-test attribute + */ + getTableSelect(): Select { + return new Select(this.page, CreateDatasetPage.SELECTORS.TABLE); + } + + /** + * Gets the create and explore button + */ + getCreateAndExploreButton(): Button { + return new Button( + this.page, + this.page.getByRole('button', { name: /Create and explore dataset/i }), + ); + } + + /** + * Navigate to the create dataset page + */ + async goto(): Promise<void> { + await this.page.goto('dataset/add/'); + } + + /** + * Select a database from the dropdown + * @param databaseName - The name of the database to select + */ + async selectDatabase(databaseName: string): Promise<void> { + await this.getDatabaseSelect().selectOption(databaseName); + } + + /** + * Select a schema from the dropdown + * @param schemaName - The name of the schema to select + */ + async selectSchema(schemaName: string): Promise<void> { + await this.getSchemaSelect().selectOption(schemaName); + } + + /** + * Select a table from the dropdown + * @param tableName - The name of the table to select + */ + async selectTable(tableName: string): Promise<void> { + await this.getTableSelect().selectOption(tableName); + } + + /** + * Click the "Create dataset" button (without exploring) + * Uses the dropdown menu to select "Create dataset" option + */ + async clickCreateDataset(): Promise<void> { + // Find the "Create and explore dataset" button, then its sibling dropdown trigger + // This avoids ambiguity if other "down" buttons exist on the page + const mainButton = this.page.getByRole('button', { + name: /Create and explore dataset/i, + }); + // The dropdown trigger is in the same button group, find it relative to main button + const dropdownTrigger = mainButton + .locator('xpath=following-sibling::button') + .first(); Review Comment: The CreateDatasetPage.clickCreateDataset method uses an XPath expression 'xpath=following-sibling::button' to find the dropdown trigger button (line 117). XPath selectors are generally more fragile than modern Playwright locators and can break if the DOM structure changes. Consider using a more robust selector strategy, such as data-test attributes or getByRole with more specific criteria, to make this locator more maintainable. ```suggestion // The dropdown trigger is in the same button group: go to the parent container // and select the second button in that group (index 1). const dropdownTrigger = mainButton .locator('..') .getByRole('button') .nth(1); ``` ########## superset-frontend/playwright/components/core/AceEditor.ts: ########## @@ -0,0 +1,200 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Locator, Page } from '@playwright/test'; + +const ACE_EDITOR_SELECTORS = { + TEXT_INPUT: '.ace_text-input', + TEXT_LAYER: '.ace_text-layer', + CONTENT: '.ace_content', + SCROLLER: '.ace_scroller', +} as const; + +/** + * AceEditor component for interacting with Ace Editor instances in Playwright. + * Uses the ace editor API directly for reliable text manipulation. + */ +export class AceEditor { + readonly page: Page; + private readonly locator: Locator; + + constructor(page: Page, selector: string); + + constructor(page: Page, locator: Locator); + + constructor(page: Page, selectorOrLocator: string | Locator) { + this.page = page; + if (typeof selectorOrLocator === 'string') { + this.locator = page.locator(selectorOrLocator); + } else { + this.locator = selectorOrLocator; + } + } + + /** + * Gets the editor element locator + */ + get element(): Locator { + return this.locator; + } + + /** + * Waits for the ace editor to be fully loaded and ready for interaction. + */ + async waitForReady(): Promise<void> { + // Wait for editor to be attached (outer .ace_editor div may be CSS-hidden) + await this.locator.waitFor({ state: 'attached' }); + await this.locator + .locator(ACE_EDITOR_SELECTORS.CONTENT) + .waitFor({ state: 'attached' }); Review Comment: The AceEditor methods (setText, getText, appendText, focus) all use page.evaluate to directly access the ace global object. If the ace library is not loaded or takes time to load, these methods will fail with 'Ace editor library not loaded' error. While waitForReady() checks for DOM elements, it doesn't verify that the ace JavaScript library is actually loaded. Consider adding a check in waitForReady() to poll for window.ace availability before proceeding with editor operations. ```suggestion .waitFor({ state: 'attached' }); // Additionally ensure the Ace library is loaded in the page before proceeding. await this.page.waitForFunction(() => { const win = window as unknown as { ace?: { edit?: unknown }; }; return !!(win.ace && typeof win.ace.edit === 'function'); }); ``` ########## superset-frontend/playwright/helpers/api/intercepts.ts: ########## @@ -0,0 +1,145 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Page, Response } from '@playwright/test'; + +/** + * HTTP methods enum for consistency + */ +export const HTTP_METHODS = { + GET: 'GET', + POST: 'POST', + PUT: 'PUT', + DELETE: 'DELETE', + PATCH: 'PATCH', +} as const; + +type HttpMethod = (typeof HTTP_METHODS)[keyof typeof HTTP_METHODS]; + +/** + * Options for waitFor* functions + */ +interface WaitForResponseOptions { + /** Optional timeout in milliseconds */ + timeout?: number; + /** Match against URL pathname suffix instead of full URL includes (default: false) */ + pathMatch?: boolean; +} + +/** + * Normalize a path by removing trailing slashes + */ +function normalizePath(path: string): string { + return path.replace(/\/+$/, ''); +} + +/** + * Check if a URL matches a pattern + * - String + pathMatch: pathname.endsWith(pattern) with trailing slash normalization + * - String: url.includes(pattern) + * - RegExp: pattern.test(url) + */ +function matchUrl( + url: string, + pattern: string | RegExp, + pathMatch?: boolean, +): boolean { + if (typeof pattern === 'string') { + if (pathMatch) { + const pathname = normalizePath(new URL(url).pathname); + const normalizedPattern = normalizePath(pattern); + return pathname.endsWith(normalizedPattern); + } + return url.includes(pattern); + } + return pattern.test(url); +} Review Comment: The matchUrl function uses new URL(url) to parse URLs when pathMatch is enabled (line 65). If the url parameter is not a valid URL (e.g., a relative path), this will throw an error. While this is likely intentional to catch malformed URLs, consider adding error handling or validation to provide a more helpful error message when an invalid URL is encountered. ########## superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts: ########## @@ -210,17 +230,431 @@ test('should duplicate a dataset with new name', async ({ page }) => { await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible(); await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible(); - // API Verification: Compare original and duplicate datasets - const originalResponseData = await apiGetDataset(page, originalId!); - const originalDataFull = await originalResponseData.json(); - const duplicateResponseData = await apiGetDataset(page, duplicateId); - const duplicateDataFull = await duplicateResponseData.json(); + // API Verification: Fetch both datasets via detail API for consistent comparison + // (list API may return undefined for fields that detail API returns as null) + const [originalDetailRes, duplicateDetailRes] = await Promise.all([ + apiGetDataset(page, original!.id), + apiGetDataset(page, duplicateId), + ]); + const originalDetail = (await originalDetailRes.json()).result; + const duplicateDetail = (await duplicateDetailRes.json()).result; // Verify key properties were copied correctly - expect(duplicateDataFull.result.sql).toBe(originalDataFull.result.sql); - expect(duplicateDataFull.result.database.id).toBe( - originalDataFull.result.database.id, - ); + expect(duplicateDetail.sql).toBe(originalDetail.sql); + expect(duplicateDetail.database.id).toBe(originalDetail.database.id); + expect(duplicateDetail.schema).toBe(originalDetail.schema); // Name should be different (the duplicate name) - expect(duplicateDataFull.result.table_name).toBe(duplicateName); + expect(duplicateDetail.table_name).toBe(duplicateName); +}); + +test('should export a dataset as a zip file', async ({ + page, + datasetListPage, +}) => { + // Use existing example dataset + const datasetName = 'members_channels_2'; + const dataset = await getDatasetByName(page, datasetName); + expect(dataset).not.toBeNull(); + + // Verify dataset is visible in list + await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible(); + + // Set up API response intercept for export endpoint + // Note: We intercept the API response instead of relying on download events because + // Superset uses blob downloads (createObjectURL) which don't trigger Playwright's + // download event consistently, especially in app-prefix configurations. + const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT); + + // Click export action button + await datasetListPage.clickExportAction(datasetName); + + // Wait for export API response and validate zip contents + const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]); + await expectValidExportZip(exportResponse, { checkContentDisposition: true }); +}); + +test('should export multiple datasets via bulk select action', async ({ + page, + datasetListPage, + testAssets, +}) => { + // Create 2 throwaway datasets for bulk export + const [dataset1, dataset2] = await Promise.all([ + createTestDataset(page, testAssets, test.info(), { + prefix: 'bulk_export_1', + }), + createTestDataset(page, testAssets, test.info(), { + prefix: 'bulk_export_2', + }), + ]); + + // Refresh to see new datasets + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + + // Verify both datasets are visible in list + await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible(); + await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible(); + + // Enable bulk select mode + await datasetListPage.clickBulkSelectButton(); + + // Select both datasets + await datasetListPage.selectDatasetCheckbox(dataset1.name); + await datasetListPage.selectDatasetCheckbox(dataset2.name); + + // Set up API response intercept for export endpoint + const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT); + + // Click bulk export action + await datasetListPage.clickBulkAction('Export'); + + // Wait for export API response and validate zip contains multiple datasets + const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]); + await expectValidExportZip(exportResponse, { minDatasetCount: 2 }); +}); + +test('should edit dataset name via modal', async ({ + page, + datasetListPage, + testAssets, +}) => { + // Create throwaway dataset for editing + const { id: datasetId, name: datasetName } = await createTestDataset( + page, + testAssets, + test.info(), + { prefix: 'test_edit' }, + ); + + // Refresh to see new dataset + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + + // Verify dataset is visible in list + await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible(); + + // Click edit action to open modal + await datasetListPage.clickEditAction(datasetName); + + // Wait for edit modal to be ready + const editModal = new EditDatasetModal(page); + await editModal.waitForReady(); + + // Enable edit mode by clicking the lock icon + await editModal.enableEditMode(); + + // Edit the dataset name + const newName = `test_renamed_${Date.now()}`; + await editModal.fillName(newName); + + // Set up response intercept for save + const saveResponsePromise = waitForPut( + page, + `${ENDPOINTS.DATASET}${datasetId}`, + ); + + // Click Save button + await editModal.clickSave(); + + // Handle the "Confirm save" dialog that appears for datasets with sync columns enabled + const confirmDialog = new ConfirmDialog(page); + await confirmDialog.clickOk(); + + // Wait for save to complete and verify success + expectStatusOneOf(await saveResponsePromise, [200, 201]); + + // Modal should close + await editModal.waitForHidden(); + + // Verify success toast appears + const toast = new Toast(page); + await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 }); + + // Verify via API that name was saved + const updatedDatasetRes = await apiGetDataset(page, datasetId); + const updatedDataset = (await updatedDatasetRes.json()).result; + expect(updatedDataset.table_name).toBe(newName); +}); + +test('should bulk delete multiple datasets', async ({ + page, + datasetListPage, + testAssets, +}) => { + // Create 2 throwaway datasets for bulk delete + const [dataset1, dataset2] = await Promise.all([ + createTestDataset(page, testAssets, test.info(), { + prefix: 'bulk_delete_1', + }), + createTestDataset(page, testAssets, test.info(), { + prefix: 'bulk_delete_2', + }), + ]); + + // Refresh to see new datasets + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + + // Verify both datasets are visible in list + await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible(); + await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible(); + + // Enable bulk select mode + await datasetListPage.clickBulkSelectButton(); + + // Select both datasets + await datasetListPage.selectDatasetCheckbox(dataset1.name); + await datasetListPage.selectDatasetCheckbox(dataset2.name); + + // Click bulk delete action + await datasetListPage.clickBulkAction('Delete'); + + // Delete confirmation modal should appear + const deleteModal = new DeleteConfirmationModal(page); + await deleteModal.waitForVisible(); + + // Type "DELETE" to confirm + await deleteModal.fillConfirmationInput('DELETE'); + + // Click the Delete button + await deleteModal.clickDelete(); + + // Modal should close + await deleteModal.waitForHidden(); + + // Verify success toast appears + const toast = new Toast(page); + await expect(toast.getSuccess()).toBeVisible(); + + // Verify both datasets are removed from list + await expect(datasetListPage.getDatasetRow(dataset1.name)).not.toBeVisible(); + await expect(datasetListPage.getDatasetRow(dataset2.name)).not.toBeVisible(); + + // Verify via API that datasets no longer exist (404) + // Use polling since deletes may be async + await expect + .poll(async () => { + const response = await apiGetDataset(page, dataset1.id, { + failOnStatusCode: false, + }); + return response.status(); + }) + .toBe(404); + await expect + .poll(async () => { + const response = await apiGetDataset(page, dataset2.id, { + failOnStatusCode: false, + }); + return response.status(); + }) + .toBe(404); +}); + +// Import test uses a fixed dataset name from the zip fixture. +// Uses test.describe only because Playwright's serial mode API requires it - +// this prevents race conditions when parallel workers import the same fixture. +// (Deviation from "avoid describe" guideline is necessary for functional reasons) +test.describe('import dataset', () => { + test.describe.configure({ mode: 'serial' }); + test('should import a dataset from a zip file', async ({ + page, + datasetListPage, + testAssets, + }) => { + // Dataset name from fixture (test_netflix_1768502050965) + // Note: Fixture contains a Google Sheets dataset - test will skip if gsheets connector unavailable + const importedDatasetName = 'test_netflix_1768502050965'; + const fixturePath = path.resolve( + __dirname, + '../../../fixtures/dataset_export.zip', + ); + + // Cleanup: Delete any existing dataset with the same name from previous runs + const existingDataset = await getDatasetByName(page, importedDatasetName); + if (existingDataset) { + await apiDeleteDataset(page, existingDataset.id, { + failOnStatusCode: false, + }); + } + + // Click the import button + await datasetListPage.clickImportButton(); + + // Wait for import modal to be ready + const importModal = new ImportDatasetModal(page); + await importModal.waitForReady(); + + // Upload the fixture zip file + await importModal.uploadFile(fixturePath); + + // Set up response intercept to catch the import POST + // Use pathMatch to avoid false matches if URL lacks trailing slash + let importResponsePromise = waitForPost(page, ENDPOINTS.DATASET_IMPORT, { + pathMatch: true, + }); + + // Click Import button + await importModal.clickImport(); + + // Wait for first import response + let importResponse = await importResponsePromise; + + // Handle overwrite confirmation if dataset already exists + // First response may be 409/422 indicating overwrite is required - this is expected + const overwriteInput = importModal.getOverwriteInput(); + await overwriteInput + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(error => { + // Only ignore TimeoutError (input not visible); re-throw other errors + if (!(error instanceof Error) || error.name !== 'TimeoutError') { + throw error; + } + }); Review Comment: The error handling pattern here only catches TimeoutError but re-throws all other errors. However, the catch block checks if the error is an instance of Error before accessing the name property. If a non-Error object is thrown (e.g., a string or null), this check will succeed but error.name will be undefined, potentially causing unexpected behavior. Consider adding a type guard or using a more defensive check like: if (error?.name !== 'TimeoutError') throw error; -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
