From d0361cb88101ccea9ff400c0aad8b42074012ac2 Mon Sep 17 00:00:00 2001 From: Joe Li Date: Tue, 16 Dec 2025 11:07:11 -0800 Subject: [PATCH] test(playwright): convert and create new dataset list playwright tests (#36196) Co-authored-by: Claude --- .github/workflows/bashlib.sh | 13 + .github/workflows/superset-e2e.yml | 2 +- .github/workflows/superset-playwright.yml | 2 +- superset-frontend/.gitignore | 3 + superset-frontend/playwright.config.ts | 31 ++- .../playwright/components/core/Modal.ts | 118 ++++++++ .../playwright/components/core/Table.ts | 102 +++++++ .../playwright/components/core/Toast.ts | 105 ++++++++ .../playwright/components/core/index.ts | 2 + .../modals/DeleteConfirmationModal.ts | 75 ++++++ .../modals/DuplicateDatasetModal.ts | 73 +++++ .../playwright/components/modals/index.ts | 22 ++ superset-frontend/playwright/global-setup.ts | 93 +++++++ .../playwright/helpers/api/database.ts | 79 ++++++ .../playwright/helpers/api/dataset.ts | 133 +++++++++ .../playwright/helpers/api/requests.ts | 193 +++++++++++++ .../playwright/pages/AuthPage.ts | 74 ++++- .../playwright/pages/DatasetListPage.ts | 115 ++++++++ .../playwright/pages/ExplorePage.ts | 88 ++++++ .../playwright/tests/auth/login.spec.ts | 127 ++++----- .../playwright/tests/experimental/README.md | 128 ++++++--- .../experimental/dataset/dataset-list.spec.ts | 254 ++++++++++++++++++ .../playwright/utils/constants.ts | 46 ++++ superset-frontend/playwright/utils/urls.ts | 11 + 24 files changed, 1778 insertions(+), 111 deletions(-) create mode 100644 superset-frontend/playwright/components/core/Modal.ts create mode 100644 superset-frontend/playwright/components/core/Table.ts create mode 100644 superset-frontend/playwright/components/core/Toast.ts create mode 100644 superset-frontend/playwright/components/modals/DeleteConfirmationModal.ts create mode 100644 superset-frontend/playwright/components/modals/DuplicateDatasetModal.ts create mode 100644 superset-frontend/playwright/components/modals/index.ts create mode 100644 superset-frontend/playwright/global-setup.ts create mode 100644 superset-frontend/playwright/helpers/api/database.ts create mode 100644 superset-frontend/playwright/helpers/api/dataset.ts create mode 100644 superset-frontend/playwright/helpers/api/requests.ts create mode 100644 superset-frontend/playwright/pages/DatasetListPage.ts create mode 100644 superset-frontend/playwright/pages/ExplorePage.ts create mode 100644 superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts create mode 100644 superset-frontend/playwright/utils/constants.ts diff --git a/.github/workflows/bashlib.sh b/.github/workflows/bashlib.sh index 1289d07259e..362f39a474a 100644 --- a/.github/workflows/bashlib.sh +++ b/.github/workflows/bashlib.sh @@ -117,6 +117,19 @@ testdata() { say "::endgroup::" } +playwright_testdata() { + cd "$GITHUB_WORKSPACE" + say "::group::Load all examples for Playwright tests" + # must specify PYTHONPATH to make `tests.superset_test_config` importable + export PYTHONPATH="$GITHUB_WORKSPACE" + pip install -e . + superset db upgrade + superset load_test_users + superset load_examples + superset init + say "::endgroup::" +} + celery-worker() { cd "$GITHUB_WORKSPACE" say "::group::Start Celery worker" diff --git a/.github/workflows/superset-e2e.yml b/.github/workflows/superset-e2e.yml index 6af3a92a970..655a3da7e27 100644 --- a/.github/workflows/superset-e2e.yml +++ b/.github/workflows/superset-e2e.yml @@ -223,7 +223,7 @@ jobs: if: steps.check.outputs.python || steps.check.outputs.frontend uses: ./.github/actions/cached-dependencies with: - run: testdata + run: playwright_testdata - name: Setup Node.js if: steps.check.outputs.python || steps.check.outputs.frontend uses: actions/setup-node@v6 diff --git a/.github/workflows/superset-playwright.yml b/.github/workflows/superset-playwright.yml index ef725141b4c..b2e2dbf6a9b 100644 --- a/.github/workflows/superset-playwright.yml +++ b/.github/workflows/superset-playwright.yml @@ -97,7 +97,7 @@ jobs: if: steps.check.outputs.python || steps.check.outputs.frontend uses: ./.github/actions/cached-dependencies with: - run: testdata + run: playwright_testdata - name: Setup Node.js if: steps.check.outputs.python || steps.check.outputs.frontend uses: actions/setup-node@v6 diff --git a/superset-frontend/.gitignore b/superset-frontend/.gitignore index a7027112bcc..359cf1ea873 100644 --- a/superset-frontend/.gitignore +++ b/superset-frontend/.gitignore @@ -1,6 +1,9 @@ coverage/* cypress/screenshots cypress/videos +playwright/.auth +playwright-report/ +test-results/ src/temp .temp_cache/ .tsbuildinfo diff --git a/superset-frontend/playwright.config.ts b/superset-frontend/playwright.config.ts index 585ccbb96f7..c4fcf3e96f6 100644 --- a/superset-frontend/playwright.config.ts +++ b/superset-frontend/playwright.config.ts @@ -33,6 +33,9 @@ export default defineConfig({ ? undefined : '**/experimental/**', + // Global setup - authenticate once before all tests + globalSetup: './playwright/global-setup.ts', + // Timeout settings timeout: 30000, expect: { timeout: 8000 }, @@ -60,7 +63,11 @@ export default defineConfig({ // Global test setup use: { // Use environment variable for base URL in CI, default to localhost:8088 for local - baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088', + // Normalize to always end with '/' to prevent URL resolution issues with APP_PREFIX + baseURL: (() => { + const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088'; + return url.endsWith('/') ? url : `${url}/`; + })(), // Browser settings headless: !!process.env.CI, @@ -77,10 +84,32 @@ export default defineConfig({ projects: [ { + // Default project - uses global authentication for speed + // E2E tests login once via global-setup.ts and reuse auth state + // Explicitly ignore auth tests (they run in chromium-unauth project) + // Also respect the global experimental testIgnore setting name: 'chromium', + testIgnore: [ + '**/tests/auth/**/*.spec.ts', + ...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']), + ], use: { browserName: 'chromium', testIdAttribute: 'data-test', + // Reuse authentication state from global setup (fast E2E tests) + storageState: 'playwright/.auth/user.json', + }, + }, + { + // Separate project for unauthenticated tests (login, signup, etc.) + // These tests use beforeEach for per-test navigation - no global auth + // This hybrid approach: simple auth tests, fast E2E tests + name: 'chromium-unauth', + testMatch: '**/tests/auth/**/*.spec.ts', + use: { + browserName: 'chromium', + testIdAttribute: 'data-test', + // No storageState = clean browser with no cached cookies }, }, ], diff --git a/superset-frontend/playwright/components/core/Modal.ts b/superset-frontend/playwright/components/core/Modal.ts new file mode 100644 index 00000000000..2b91369113c --- /dev/null +++ b/superset-frontend/playwright/components/core/Modal.ts @@ -0,0 +1,118 @@ +/** + * 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'; + +/** + * Base Modal component for Ant Design modals. + * Provides minimal primitives - extend this for specific modal types. + * Add methods to this class only when multiple modal types need them (YAGNI). + * + * @example + * class DeleteConfirmationModal extends Modal { + * async clickDelete(): Promise { + * await this.footer.locator('button', { hasText: 'Delete' }).click(); + * } + * } + */ +export class Modal { + protected readonly page: Page; + protected readonly modalSelector: string; + + // Ant Design modal structure selectors (shared by all modal types) + protected static readonly BASE_SELECTORS = { + FOOTER: '.ant-modal-footer', + BODY: '.ant-modal-body', + }; + + constructor(page: Page, modalSelector = '[role="dialog"]') { + this.page = page; + this.modalSelector = modalSelector; + } + + /** + * Gets the modal element locator + */ + get element(): Locator { + return this.page.locator(this.modalSelector); + } + + /** + * Gets the modal footer locator (contains action buttons) + */ + get footer(): Locator { + return this.element.locator(Modal.BASE_SELECTORS.FOOTER); + } + + /** + * Gets the modal body locator (contains content) + */ + get body(): Locator { + return this.element.locator(Modal.BASE_SELECTORS.BODY); + } + + /** + * Gets a footer button by text content (private helper) + * @param buttonText - The text content of the button + */ + private getFooterButton(buttonText: string): Locator { + return this.footer.getByRole('button', { name: buttonText, exact: true }); + } + + /** + * Clicks a footer button by text content + * @param buttonText - The text content of the button to click + * @param options - Optional click options + */ + protected async clickFooterButton( + buttonText: string, + options?: { timeout?: number; force?: boolean; delay?: number }, + ): Promise { + await this.getFooterButton(buttonText).click(options); + } + + /** + * Waits for the modal to become visible + * @param options - Optional wait options + */ + async waitForVisible(options?: { timeout?: number }): Promise { + await this.element.waitFor({ state: 'visible', ...options }); + } + + /** + * Waits for the modal to be fully ready for interaction. + * This includes waiting for the modal dialog to be visible AND for React to finish + * rendering the modal content. Use this before interacting with modal elements + * to avoid race conditions with React state updates. + * + * @param options - Optional wait options + */ + async waitForReady(options?: { timeout?: number }): Promise { + await this.waitForVisible(options); + await this.body.waitFor({ state: 'visible', ...options }); + } + + /** + * Waits for the modal to be hidden + * @param options - Optional wait options + */ + async waitForHidden(options?: { timeout?: number }): Promise { + await this.element.waitFor({ state: 'hidden', ...options }); + } +} diff --git a/superset-frontend/playwright/components/core/Table.ts b/superset-frontend/playwright/components/core/Table.ts new file mode 100644 index 00000000000..d3f7a67b14f --- /dev/null +++ b/superset-frontend/playwright/components/core/Table.ts @@ -0,0 +1,102 @@ +/** + * 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'; + +/** + * Table component for Superset ListView tables. + */ +export class Table { + private readonly page: Page; + private readonly tableSelector: string; + + private static readonly SELECTORS = { + TABLE_ROW: '[data-test="table-row"]', + }; + + constructor(page: Page, tableSelector = '[data-test="listview-table"]') { + this.page = page; + this.tableSelector = tableSelector; + } + + /** + * Gets the table element locator + */ + get element(): Locator { + return this.page.locator(this.tableSelector); + } + + /** + * Gets a table row by exact text match in the first cell (dataset name column). + * Uses exact match to avoid substring collisions (e.g., 'members_channels_2' vs 'duplicate_members_channels_2_123'). + * + * Note: Returns a Locator that will auto-wait when used in assertions or actions. + * If row doesn't exist, operations on the locator will timeout with clear error. + * + * @param rowText - Exact text to find in the row's first cell + * @returns Locator for the matching row + */ + getRow(rowText: string): Locator { + return this.element.locator(Table.SELECTORS.TABLE_ROW).filter({ + has: this.page.getByRole('cell', { name: rowText, exact: true }), + }); + } + + /** + * Clicks a link within a specific row + * @param rowText - Text to identify the row + * @param linkSelector - Selector for the link within the row + */ + async clickRowLink(rowText: string, linkSelector: string): Promise { + const row = this.getRow(rowText); + await row.locator(linkSelector).click(); + } + + /** + * Waits for the table to be visible + * @param options - Optional wait options + */ + async waitForVisible(options?: { timeout?: number }): Promise { + await this.element.waitFor({ state: 'visible', ...options }); + } + + /** + * Clicks an action button in a row by selector + * @param rowText - Text to identify the row + * @param selector - CSS selector for the action element + */ + async clickRowAction(rowText: string, selector: string): Promise { + const row = this.getRow(rowText); + const actionButton = row.locator(selector); + + const count = await actionButton.count(); + if (count === 0) { + throw new Error( + `No action button found with selector "${selector}" in row "${rowText}"`, + ); + } + if (count > 1) { + throw new Error( + `Multiple action buttons (${count}) found with selector "${selector}" in row "${rowText}". Use more specific selector.`, + ); + } + + await actionButton.click(); + } +} diff --git a/superset-frontend/playwright/components/core/Toast.ts b/superset-frontend/playwright/components/core/Toast.ts new file mode 100644 index 00000000000..a291e03c0de --- /dev/null +++ b/superset-frontend/playwright/components/core/Toast.ts @@ -0,0 +1,105 @@ +/** + * 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'; + +export type ToastType = 'success' | 'danger' | 'warning' | 'info'; + +const SELECTORS = { + CONTAINER: '[data-test="toast-container"][role="alert"]', + CONTENT: '.toast__content', + CLOSE_BUTTON: '[data-test="close-button"]', +} as const; + +/** + * Toast notification component + * Handles success, danger, warning, and info toasts + */ +export class Toast { + private page: Page; + private container: Locator; + + constructor(page: Page) { + this.page = page; + this.container = page.locator(SELECTORS.CONTAINER); + } + + /** + * Get the toast container locator + */ + get(): Locator { + return this.container; + } + + /** + * Get the toast message text + */ + getMessage(): Locator { + return this.container.locator(SELECTORS.CONTENT); + } + + /** + * Wait for a toast to appear + */ + async waitForVisible(): Promise { + await this.container.waitFor({ state: 'visible' }); + } + + /** + * Wait for toast to disappear + */ + async waitForHidden(): Promise { + await this.container.waitFor({ state: 'hidden' }); + } + + /** + * Get a success toast + */ + getSuccess(): Locator { + return this.page.locator(`${SELECTORS.CONTAINER}.toast--success`); + } + + /** + * Get a danger/error toast + */ + getDanger(): Locator { + return this.page.locator(`${SELECTORS.CONTAINER}.toast--danger`); + } + + /** + * Get a warning toast + */ + getWarning(): Locator { + return this.page.locator(`${SELECTORS.CONTAINER}.toast--warning`); + } + + /** + * Get an info toast + */ + getInfo(): Locator { + return this.page.locator(`${SELECTORS.CONTAINER}.toast--info`); + } + + /** + * Close the toast by clicking the close button + */ + async close(): Promise { + await this.container.locator(SELECTORS.CLOSE_BUTTON).click(); + } +} diff --git a/superset-frontend/playwright/components/core/index.ts b/superset-frontend/playwright/components/core/index.ts index 3d99379e99f..82a26c2b695 100644 --- a/superset-frontend/playwright/components/core/index.ts +++ b/superset-frontend/playwright/components/core/index.ts @@ -21,3 +21,5 @@ export { Button } from './Button'; export { Form } from './Form'; export { Input } from './Input'; +export { Modal } from './Modal'; +export { Table } from './Table'; diff --git a/superset-frontend/playwright/components/modals/DeleteConfirmationModal.ts b/superset-frontend/playwright/components/modals/DeleteConfirmationModal.ts new file mode 100644 index 00000000000..44dca9e6b26 --- /dev/null +++ b/superset-frontend/playwright/components/modals/DeleteConfirmationModal.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 { Modal, Input } from '../core'; + +/** + * Delete confirmation modal that requires typing "DELETE" to confirm. + * Used throughout Superset for destructive delete operations. + * + * Provides primitives for tests to compose deletion flows. + */ +export class DeleteConfirmationModal extends Modal { + private static readonly SELECTORS = { + CONFIRMATION_INPUT: 'input[type="text"]', + }; + + /** + * Gets the confirmation input component + */ + private get confirmationInput(): Input { + return new Input( + this.page, + this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT), + ); + } + + /** + * Fills the confirmation input with the specified text. + * + * @param confirmationText - The text to type + * @param options - Optional fill options (timeout, force) + * + * @example + * const deleteModal = new DeleteConfirmationModal(page); + * await deleteModal.waitForVisible(); + * await deleteModal.fillConfirmationInput('DELETE'); + * await deleteModal.clickDelete(); + * await deleteModal.waitForHidden(); + */ + async fillConfirmationInput( + confirmationText: string, + options?: { timeout?: number; force?: boolean }, + ): Promise { + await this.confirmationInput.fill(confirmationText, options); + } + + /** + * Clicks the Delete button in the footer + * + * @param options - Optional click options (timeout, force, delay) + */ + async clickDelete(options?: { + timeout?: number; + force?: boolean; + delay?: number; + }): Promise { + await this.clickFooterButton('Delete', options); + } +} diff --git a/superset-frontend/playwright/components/modals/DuplicateDatasetModal.ts b/superset-frontend/playwright/components/modals/DuplicateDatasetModal.ts new file mode 100644 index 00000000000..68a4fdb1326 --- /dev/null +++ b/superset-frontend/playwright/components/modals/DuplicateDatasetModal.ts @@ -0,0 +1,73 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Modal, Input } from '../core'; + +/** + * Duplicate dataset modal that requires entering a new dataset name. + * Used for duplicating virtual datasets with custom SQL. + */ +export class DuplicateDatasetModal extends Modal { + private static readonly SELECTORS = { + NAME_INPUT: '[data-test="duplicate-modal-input"]', + }; + + /** + * Gets the new dataset name input component + */ + private get nameInput(): Input { + return new Input( + this.page, + this.body.locator(DuplicateDatasetModal.SELECTORS.NAME_INPUT), + ); + } + + /** + * Fills the new dataset name input + * + * @param datasetName - The new name for the duplicated dataset + * @param options - Optional fill options (timeout, force) + * + * @example + * const duplicateModal = new DuplicateDatasetModal(page); + * await duplicateModal.waitForVisible(); + * await duplicateModal.fillDatasetName('my_dataset_copy'); + * await duplicateModal.clickDuplicate(); + * await duplicateModal.waitForHidden(); + */ + async fillDatasetName( + datasetName: string, + options?: { timeout?: number; force?: boolean }, + ): Promise { + await this.nameInput.fill(datasetName, options); + } + + /** + * Clicks the Duplicate button in the footer + * + * @param options - Optional click options (timeout, force, delay) + */ + async clickDuplicate(options?: { + timeout?: number; + force?: boolean; + delay?: number; + }): Promise { + await this.clickFooterButton('Duplicate', options); + } +} diff --git a/superset-frontend/playwright/components/modals/index.ts b/superset-frontend/playwright/components/modals/index.ts new file mode 100644 index 00000000000..83356921ada --- /dev/null +++ b/superset-frontend/playwright/components/modals/index.ts @@ -0,0 +1,22 @@ +/** + * 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. + */ + +// Specific modal implementations +export { DeleteConfirmationModal } from './DeleteConfirmationModal'; +export { DuplicateDatasetModal } from './DuplicateDatasetModal'; diff --git a/superset-frontend/playwright/global-setup.ts b/superset-frontend/playwright/global-setup.ts new file mode 100644 index 00000000000..f06f610fe0f --- /dev/null +++ b/superset-frontend/playwright/global-setup.ts @@ -0,0 +1,93 @@ +/** + * 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 { + chromium, + FullConfig, + Browser, + BrowserContext, +} from '@playwright/test'; +import { mkdir } from 'fs/promises'; +import { dirname } from 'path'; +import { AuthPage } from './pages/AuthPage'; +import { TIMEOUT } from './utils/constants'; + +/** + * Global setup function that runs once before all tests. + * Authenticates as admin user and saves the authentication state + * to be reused by tests in the 'chromium' project (E2E tests). + * + * Auth tests (chromium-unauth project) don't use this - they login + * per-test via beforeEach for isolation and simplicity. + */ +async function globalSetup(config: FullConfig) { + // Get baseURL with fallback to default + // FullConfig.use doesn't exist in the type - baseURL is only in projects[0].use + const baseURL = config.projects[0]?.use?.baseURL || 'http://localhost:8088'; + + // Test credentials - can be overridden via environment variables + const adminUsername = process.env.PLAYWRIGHT_ADMIN_USERNAME || 'admin'; + const adminPassword = process.env.PLAYWRIGHT_ADMIN_PASSWORD || 'general'; + + console.log('[Global Setup] Authenticating as admin user...'); + + let browser: Browser | null = null; + let context: BrowserContext | null = null; + + try { + // Launch browser + browser = await chromium.launch(); + } catch (error) { + console.error('[Global Setup] Failed to launch browser:', error); + throw new Error('Browser launch failed - check Playwright installation'); + } + + try { + context = await browser.newContext({ baseURL }); + const page = await context.newPage(); + + // Use AuthPage to handle login logic (DRY principle) + const authPage = new AuthPage(page); + await authPage.goto(); + await authPage.waitForLoginForm(); + await authPage.loginWithCredentials(adminUsername, adminPassword); + // Use longer timeout for global setup (cold CI starts may exceed PAGE_LOAD timeout) + await authPage.waitForLoginSuccess({ timeout: TIMEOUT.GLOBAL_SETUP }); + + // Save authentication state for all tests to reuse + const authStatePath = 'playwright/.auth/user.json'; + await mkdir(dirname(authStatePath), { recursive: true }); + await context.storageState({ + path: authStatePath, + }); + + console.log( + '[Global Setup] Authentication successful - state saved to playwright/.auth/user.json', + ); + } catch (error) { + console.error('[Global Setup] Authentication failed:', error); + throw error; + } finally { + // Ensure cleanup even if auth fails + if (context) await context.close(); + if (browser) await browser.close(); + } +} + +export default globalSetup; diff --git a/superset-frontend/playwright/helpers/api/database.ts b/superset-frontend/playwright/helpers/api/database.ts new file mode 100644 index 00000000000..31955393ca7 --- /dev/null +++ b/superset-frontend/playwright/helpers/api/database.ts @@ -0,0 +1,79 @@ +/** + * 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, APIResponse } from '@playwright/test'; +import { apiPost, apiDelete, ApiRequestOptions } from './requests'; + +const ENDPOINTS = { + DATABASE: 'api/v1/database/', +} as const; + +/** + * TypeScript interface for database creation API payload + * Provides compile-time safety for required fields + */ +export interface DatabaseCreatePayload { + database_name: string; + engine: string; + configuration_method?: string; + engine_information?: { + disable_ssh_tunneling?: boolean; + supports_dynamic_catalog?: boolean; + supports_file_upload?: boolean; + supports_oauth2?: boolean; + }; + driver?: string; + sqlalchemy_uri_placeholder?: string; + extra?: string; + expose_in_sqllab?: boolean; + catalog?: Array<{ name: string; value: string }>; + parameters?: { + service_account_info?: string; + catalog?: Record; + }; + masked_encrypted_extra?: string; + impersonate_user?: boolean; +} + +/** + * POST request to create a database connection + * @param page - Playwright page instance (provides authentication context) + * @param requestBody - Database configuration object with type safety + * @returns API response from database creation + */ +export async function apiPostDatabase( + page: Page, + requestBody: DatabaseCreatePayload, +): Promise { + return apiPost(page, ENDPOINTS.DATABASE, requestBody); +} + +/** + * DELETE request to remove a database connection + * @param page - Playwright page instance (provides authentication context) + * @param databaseId - ID of the database to delete + * @returns API response from database deletion + */ +export async function apiDeleteDatabase( + page: Page, + databaseId: number, + options?: ApiRequestOptions, +): Promise { + return apiDelete(page, `${ENDPOINTS.DATABASE}${databaseId}`, options); +} diff --git a/superset-frontend/playwright/helpers/api/dataset.ts b/superset-frontend/playwright/helpers/api/dataset.ts new file mode 100644 index 00000000000..0903df7adcc --- /dev/null +++ b/superset-frontend/playwright/helpers/api/dataset.ts @@ -0,0 +1,133 @@ +/** + * 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, APIResponse } from '@playwright/test'; +import rison from 'rison'; +import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests'; + +export const ENDPOINTS = { + DATASET: 'api/v1/dataset/', +} as const; + +/** + * TypeScript interface for dataset creation API payload + * Provides compile-time safety for required fields + */ +export interface DatasetCreatePayload { + database: number; + catalog: string | null; + schema: string; + table_name: string; +} + +/** + * TypeScript interface for dataset API response + * Represents the shape of dataset data returned from the API + */ +export interface DatasetResult { + id: number; + table_name: string; + sql?: string; + schema?: string; + database: { + id: number; + database_name: string; + }; + owners?: Array<{ id: number }>; + dataset_type?: 'physical' | 'virtual'; +} + +/** + * POST request to create a dataset + * @param page - Playwright page instance (provides authentication context) + * @param requestBody - Dataset configuration object (database, schema, table_name) + * @returns API response from dataset creation + */ +export async function apiPostDataset( + page: Page, + requestBody: DatasetCreatePayload, +): Promise { + return apiPost(page, ENDPOINTS.DATASET, requestBody); +} + +/** + * Get a dataset by its table name + * @param page - Playwright page instance (provides authentication context) + * @param tableName - The table_name to search for + * @returns Dataset object if found, null if not found + */ +export async function getDatasetByName( + page: Page, + tableName: string, +): Promise { + // Use Superset's filter API to search by table_name + const filter = { + filters: [ + { + col: 'table_name', + opr: 'eq', + value: tableName, + }, + ], + }; + const queryParam = rison.encode(filter); + // Use failOnStatusCode: false so we return null instead of throwing on errors + const response = await apiGet(page, `${ENDPOINTS.DATASET}?q=${queryParam}`, { + failOnStatusCode: false, + }); + + if (!response.ok()) { + return null; + } + + const body = await response.json(); + if (body.result && body.result.length > 0) { + return body.result[0] as DatasetResult; + } + + return null; +} + +/** + * GET request to fetch a dataset's details + * @param page - Playwright page instance (provides authentication context) + * @param datasetId - ID of the dataset to fetch + * @returns API response with dataset details + */ +export async function apiGetDataset( + page: Page, + datasetId: number, + options?: ApiRequestOptions, +): Promise { + return apiGet(page, `${ENDPOINTS.DATASET}${datasetId}`, options); +} + +/** + * DELETE request to remove a dataset + * @param page - Playwright page instance (provides authentication context) + * @param datasetId - ID of the dataset to delete + * @returns API response from dataset deletion + */ +export async function apiDeleteDataset( + page: Page, + datasetId: number, + options?: ApiRequestOptions, +): Promise { + return apiDelete(page, `${ENDPOINTS.DATASET}${datasetId}`, options); +} diff --git a/superset-frontend/playwright/helpers/api/requests.ts b/superset-frontend/playwright/helpers/api/requests.ts new file mode 100644 index 00000000000..9705d5e9b9c --- /dev/null +++ b/superset-frontend/playwright/helpers/api/requests.ts @@ -0,0 +1,193 @@ +/** + * 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, APIResponse } from '@playwright/test'; + +export interface ApiRequestOptions { + headers?: Record; + params?: Record; + failOnStatusCode?: boolean; + allowMissingCsrf?: boolean; +} + +/** + * Get base URL for Referer header + * Reads from environment variable configured in playwright.config.ts + * Preserves full base URL including path prefix (e.g., /app/prefix/) + * Normalizes to always end with '/' for consistent URL resolution + */ +function getBaseUrl(): string { + // Use environment variable which includes path prefix if configured + // Normalize to always end with '/' (matches playwright.config.ts normalization) + const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088'; + return url.endsWith('/') ? url : `${url}/`; +} + +interface CsrfResult { + token: string; + error?: string; +} + +/** + * Get CSRF token from the API endpoint + * Superset provides a CSRF token via api/v1/security/csrf_token/ + * The session cookie is automatically included by page.request + */ +async function getCsrfToken(page: Page): Promise { + try { + const response = await page.request.get('api/v1/security/csrf_token/', { + failOnStatusCode: false, + }); + + if (!response.ok()) { + return { + token: '', + error: `HTTP ${response.status()} ${response.statusText()}`, + }; + } + + const json = await response.json(); + return { token: json.result || '' }; + } catch (error) { + return { token: '', error: String(error) }; + } +} + +/** + * Build headers for mutation requests (POST, PUT, PATCH, DELETE) + * Includes CSRF token and Referer for Flask-WTF CSRFProtect + */ +async function buildHeaders( + page: Page, + options?: ApiRequestOptions, +): Promise> { + const { token: csrfToken, error: csrfError } = await getCsrfToken(page); + const headers: Record = { + 'Content-Type': 'application/json', + ...options?.headers, + }; + + // Include CSRF token and Referer for Flask-WTF CSRFProtect + if (csrfToken) { + headers['X-CSRFToken'] = csrfToken; + headers['Referer'] = getBaseUrl(); + } else if (!options?.allowMissingCsrf) { + const errorDetail = csrfError ? ` (${csrfError})` : ''; + throw new Error( + `Missing CSRF token${errorDetail} - mutation requests require authentication. ` + + 'Ensure global authentication completed or test has valid session.', + ); + } + + return headers; +} + +/** + * Send a GET request + * Uses page.request to automatically include browser authentication + */ +export async function apiGet( + page: Page, + url: string, + options?: ApiRequestOptions, +): Promise { + return page.request.get(url, { + headers: options?.headers, + params: options?.params, + failOnStatusCode: options?.failOnStatusCode ?? true, + }); +} + +/** + * Send a POST request + * Uses page.request to automatically include browser authentication + */ +export async function apiPost( + page: Page, + url: string, + data?: unknown, + options?: ApiRequestOptions, +): Promise { + const headers = await buildHeaders(page, options); + + return page.request.post(url, { + data, + headers, + params: options?.params, + failOnStatusCode: options?.failOnStatusCode ?? true, + }); +} + +/** + * Send a PUT request + * Uses page.request to automatically include browser authentication + */ +export async function apiPut( + page: Page, + url: string, + data?: unknown, + options?: ApiRequestOptions, +): Promise { + const headers = await buildHeaders(page, options); + + return page.request.put(url, { + data, + headers, + params: options?.params, + failOnStatusCode: options?.failOnStatusCode ?? true, + }); +} + +/** + * Send a PATCH request + * Uses page.request to automatically include browser authentication + */ +export async function apiPatch( + page: Page, + url: string, + data?: unknown, + options?: ApiRequestOptions, +): Promise { + const headers = await buildHeaders(page, options); + + return page.request.patch(url, { + data, + headers, + params: options?.params, + failOnStatusCode: options?.failOnStatusCode ?? true, + }); +} + +/** + * Send a DELETE request + * Uses page.request to automatically include browser authentication + */ +export async function apiDelete( + page: Page, + url: string, + options?: ApiRequestOptions, +): Promise { + const headers = await buildHeaders(page, options); + + return page.request.delete(url, { + headers, + params: options?.params, + failOnStatusCode: options?.failOnStatusCode ?? true, + }); +} diff --git a/superset-frontend/playwright/pages/AuthPage.ts b/superset-frontend/playwright/pages/AuthPage.ts index a925ceaae83..2cb5157e144 100644 --- a/superset-frontend/playwright/pages/AuthPage.ts +++ b/superset-frontend/playwright/pages/AuthPage.ts @@ -17,9 +17,10 @@ * under the License. */ -import { Page, Response } from '@playwright/test'; +import { Page, Response, Cookie } from '@playwright/test'; import { Form } from '../components/core'; import { URL } from '../utils/urls'; +import { TIMEOUT } from '../utils/constants'; export class AuthPage { private readonly page: Page; @@ -56,7 +57,7 @@ export class AuthPage { * Wait for login form to be visible */ async waitForLoginForm(): Promise { - await this.loginForm.waitForVisible({ timeout: 5000 }); + await this.loginForm.waitForVisible({ timeout: TIMEOUT.FORM_LOAD }); } /** @@ -83,6 +84,67 @@ export class AuthPage { await loginButton.click(); } + /** + * Wait for successful login by verifying the login response and session cookie. + * Call this after loginWithCredentials to ensure authentication completed. + * + * This does NOT assume a specific landing page (which is configurable). + * Instead it: + * 1. Checks if session cookie already exists (guards against race condition) + * 2. Waits for POST /login/ response with redirect status + * 3. Polls for session cookie to appear + * + * @param options - Optional wait options + */ + async waitForLoginSuccess(options?: { timeout?: number }): Promise { + const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD; + const startTime = Date.now(); + + // 1. Guard: Check if session cookie already exists (race condition protection) + const existingCookie = await this.getSessionCookie(); + if (existingCookie?.value) { + // Already authenticated - login completed before we started waiting + return; + } + + // 2. Wait for POST /login/ response (bounded by caller's timeout) + const loginResponse = await this.page.waitForResponse( + response => + response.url().includes('/login/') && + response.request().method() === 'POST', + { timeout }, + ); + + // 3. Verify it's a redirect (3xx status code indicates successful login) + const status = loginResponse.status(); + if (status < 300 || status >= 400) { + throw new Error(`Login failed: expected redirect (3xx), got ${status}`); + } + + // 4. Poll for session cookie to appear (HttpOnly cookie, not accessible via document.cookie) + // Use page.context().cookies() since session cookie is HttpOnly + const pollInterval = 500; // 500ms instead of 100ms for less chattiness + while (true) { + const remaining = timeout - (Date.now() - startTime); + if (remaining <= 0) { + break; // Timeout exceeded + } + + const sessionCookie = await this.getSessionCookie(); + if (sessionCookie && sessionCookie.value) { + // Success - session cookie has landed + return; + } + + await this.page.waitForTimeout(Math.min(pollInterval, remaining)); + } + + const currentUrl = await this.page.url(); + throw new Error( + `Login timeout: session cookie did not appear within ${timeout}ms. Current URL: ${currentUrl}`, + ); + } + /** * Get current page URL */ @@ -93,9 +155,9 @@ export class AuthPage { /** * Get the session cookie specifically */ - async getSessionCookie(): Promise<{ name: string; value: string } | null> { + async getSessionCookie(): Promise { const cookies = await this.page.context().cookies(); - return cookies.find((c: any) => c.name === 'session') || null; + return cookies.find(c => c.name === 'session') || null; } /** @@ -106,7 +168,7 @@ export class AuthPage { selector => this.page.locator(selector).isVisible(), ); const visibilityResults = await Promise.all(visibilityPromises); - return visibilityResults.some((isVisible: any) => isVisible); + return visibilityResults.some(isVisible => isVisible); } /** @@ -114,7 +176,7 @@ export class AuthPage { */ async waitForLoginRequest(): Promise { return this.page.waitForResponse( - (response: any) => + response => response.url().includes('/login/') && response.request().method() === 'POST', ); diff --git a/superset-frontend/playwright/pages/DatasetListPage.ts b/superset-frontend/playwright/pages/DatasetListPage.ts new file mode 100644 index 00000000000..a7b6af75a18 --- /dev/null +++ b/superset-frontend/playwright/pages/DatasetListPage.ts @@ -0,0 +1,115 @@ +/** + * 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 { Table } from '../components/core'; +import { URL } from '../utils/urls'; + +/** + * Dataset List Page object. + */ +export class DatasetListPage { + private readonly page: Page; + private readonly table: Table; + + private static readonly SELECTORS = { + DATASET_LINK: '[data-test="internal-link"]', + DELETE_ACTION: '.action-button svg[data-icon="delete"]', + EXPORT_ACTION: '.action-button svg[data-icon="upload"]', + DUPLICATE_ACTION: '.action-button svg[data-icon="copy"]', + } as const; + + constructor(page: Page) { + this.page = page; + this.table = new Table(page); + } + + /** + * Navigate to the dataset list page + */ + async goto(): Promise { + await this.page.goto(URL.DATASET_LIST); + } + + /** + * Wait for the table to load + * @param options - Optional wait options + */ + async waitForTableLoad(options?: { timeout?: number }): Promise { + await this.table.waitForVisible(options); + } + + /** + * Gets a dataset row locator by name. + * Returns a Locator that tests can use with expect().toBeVisible(), etc. + * + * @param datasetName - The name of the dataset + * @returns Locator for the dataset row + * + * @example + * await expect(datasetListPage.getDatasetRow('birth_names')).toBeVisible(); + */ + getDatasetRow(datasetName: string): Locator { + return this.table.getRow(datasetName); + } + + /** + * Clicks on a dataset name to navigate to Explore + * @param datasetName - The name of the dataset to click + */ + async clickDatasetName(datasetName: string): Promise { + await this.table.clickRowLink( + datasetName, + DatasetListPage.SELECTORS.DATASET_LINK, + ); + } + + /** + * Clicks the delete action button for a dataset + * @param datasetName - The name of the dataset to delete + */ + async clickDeleteAction(datasetName: string): Promise { + await this.table.clickRowAction( + datasetName, + DatasetListPage.SELECTORS.DELETE_ACTION, + ); + } + + /** + * Clicks the export action button for a dataset + * @param datasetName - The name of the dataset to export + */ + async clickExportAction(datasetName: string): Promise { + await this.table.clickRowAction( + datasetName, + DatasetListPage.SELECTORS.EXPORT_ACTION, + ); + } + + /** + * Clicks the duplicate action button for a dataset (virtual datasets only) + * @param datasetName - The name of the dataset to duplicate + */ + async clickDuplicateAction(datasetName: string): Promise { + await this.table.clickRowAction( + datasetName, + DatasetListPage.SELECTORS.DUPLICATE_ACTION, + ); + } +} diff --git a/superset-frontend/playwright/pages/ExplorePage.ts b/superset-frontend/playwright/pages/ExplorePage.ts new file mode 100644 index 00000000000..5581fdb7a77 --- /dev/null +++ b/superset-frontend/playwright/pages/ExplorePage.ts @@ -0,0 +1,88 @@ +/** + * 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 { TIMEOUT } from '../utils/constants'; + +/** + * Explore Page object + */ +export class ExplorePage { + private readonly page: Page; + + private static readonly SELECTORS = { + DATASOURCE_CONTROL: '[data-test="datasource-control"]', + VIZ_SWITCHER: '[data-test="fast-viz-switcher"]', + } as const; + + constructor(page: Page) { + this.page = page; + } + + /** + * Waits for the Explore page to load. + * Validates URL contains /explore/ and datasource control is visible. + * + * @param options - Optional wait options + */ + async waitForPageLoad(options?: { timeout?: number }): Promise { + const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD; + + await this.page.waitForURL('**/explore/**', { timeout }); + + await this.page.waitForSelector(ExplorePage.SELECTORS.DATASOURCE_CONTROL, { + state: 'visible', + timeout, + }); + } + + /** + * Gets the datasource control locator. + * Returns a Locator that tests can use with expect() or to read text. + * + * @returns Locator for the datasource control + * + * @example + * const name = await explorePage.getDatasourceControl().textContent(); + */ + getDatasourceControl(): Locator { + return this.page.locator(ExplorePage.SELECTORS.DATASOURCE_CONTROL); + } + + /** + * Gets the currently selected dataset name from the datasource control + */ + async getDatasetName(): Promise { + const text = await this.getDatasourceControl().textContent(); + return text?.trim() || ''; + } + + /** + * Gets the visualization switcher locator. + * Returns a Locator that tests can use with expect().toBeVisible(), etc. + * + * @returns Locator for the viz switcher + * + * @example + * await expect(explorePage.getVizSwitcher()).toBeVisible(); + */ + getVizSwitcher(): Locator { + return this.page.locator(ExplorePage.SELECTORS.VIZ_SWITCHER); + } +} diff --git a/superset-frontend/playwright/tests/auth/login.spec.ts b/superset-frontend/playwright/tests/auth/login.spec.ts index 713cd9c1a76..38fd48ee957 100644 --- a/superset-frontend/playwright/tests/auth/login.spec.ts +++ b/superset-frontend/playwright/tests/auth/login.spec.ts @@ -20,69 +20,74 @@ import { test, expect } from '@playwright/test'; import { AuthPage } from '../../pages/AuthPage'; import { URL } from '../../utils/urls'; +import { TIMEOUT } from '../../utils/constants'; -test.describe('Login view', () => { - let authPage: AuthPage; +// Test credentials - can be overridden via environment variables +const adminUsername = process.env.PLAYWRIGHT_ADMIN_USERNAME || 'admin'; +const adminPassword = process.env.PLAYWRIGHT_ADMIN_PASSWORD || 'general'; - test.beforeEach(async ({ page }: any) => { - authPage = new AuthPage(page); - await authPage.goto(); - await authPage.waitForLoginForm(); - }); +/** + * Auth/login tests use per-test navigation via beforeEach. + * Each test starts fresh on the login page without global authentication. + * This follows the Cypress pattern for auth testing - simple and isolated. + */ - test('should redirect to login with incorrect username and password', async ({ - page, - }: any) => { - // Setup request interception before login attempt - const loginRequestPromise = authPage.waitForLoginRequest(); +let authPage: AuthPage; - // Attempt login with incorrect credentials - await authPage.loginWithCredentials('admin', 'wrongpassword'); - - // Wait for login request and verify response - const loginResponse = await loginRequestPromise; - // Failed login returns 401 Unauthorized or 302 redirect to login - expect([401, 302]).toContain(loginResponse.status()); - - // Wait for redirect to complete before checking URL - await page.waitForURL((url: any) => url.pathname.endsWith('login/'), { - timeout: 10000, - }); - - // Verify we stay on login page - const currentUrl = await authPage.getCurrentUrl(); - expect(currentUrl).toContain(URL.LOGIN); - - // Verify error message is shown - const hasError = await authPage.hasLoginError(); - expect(hasError).toBe(true); - }); - - test('should login with correct username and password', async ({ - page, - }: any) => { - // Setup request interception before login attempt - const loginRequestPromise = authPage.waitForLoginRequest(); - - // Login with correct credentials - await authPage.loginWithCredentials('admin', 'general'); - - // Wait for login request and verify response - const loginResponse = await loginRequestPromise; - // Successful login returns 302 redirect - expect(loginResponse.status()).toBe(302); - - // Wait for successful redirect to welcome page - await page.waitForURL( - (url: any) => url.pathname.endsWith('superset/welcome/'), - { - timeout: 10000, - }, - ); - - // Verify specific session cookie exists - const sessionCookie = await authPage.getSessionCookie(); - expect(sessionCookie).not.toBeNull(); - expect(sessionCookie?.value).toBeTruthy(); - }); +test.beforeEach(async ({ page }) => { + // Navigate to login page before each test (ensures clean state) + authPage = new AuthPage(page); + await authPage.goto(); + await authPage.waitForLoginForm(); +}); + +test('should redirect to login with incorrect username and password', async ({ + page, +}) => { + // Setup request interception before login attempt + const loginRequestPromise = authPage.waitForLoginRequest(); + + // Attempt login with incorrect credentials (both username and password invalid) + await authPage.loginWithCredentials('wronguser', 'wrongpassword'); + + // Wait for login request and verify response + const loginResponse = await loginRequestPromise; + // Failed login returns 401 Unauthorized or 302 redirect to login + expect([401, 302]).toContain(loginResponse.status()); + + // Wait for redirect to complete before checking URL + await page.waitForURL(url => url.pathname.endsWith(URL.LOGIN), { + timeout: TIMEOUT.PAGE_LOAD, + }); + + // Verify we stay on login page + const currentUrl = await authPage.getCurrentUrl(); + expect(currentUrl).toContain(URL.LOGIN); + + // Verify error message is shown + const hasError = await authPage.hasLoginError(); + expect(hasError).toBe(true); +}); + +test('should login with correct username and password', async ({ page }) => { + // Setup request interception before login attempt + const loginRequestPromise = authPage.waitForLoginRequest(); + + // Login with correct credentials + await authPage.loginWithCredentials(adminUsername, adminPassword); + + // Wait for login request and verify response + const loginResponse = await loginRequestPromise; + // Successful login returns 302 redirect + expect(loginResponse.status()).toBe(302); + + // Wait for successful redirect to welcome page + await page.waitForURL(url => url.pathname.endsWith(URL.WELCOME), { + timeout: TIMEOUT.PAGE_LOAD, + }); + + // Verify specific session cookie exists + const sessionCookie = await authPage.getSessionCookie(); + expect(sessionCookie).not.toBeNull(); + expect(sessionCookie?.value).toBeTruthy(); }); diff --git a/superset-frontend/playwright/tests/experimental/README.md b/superset-frontend/playwright/tests/experimental/README.md index 9647fb23960..a1511695b18 100644 --- a/superset-frontend/playwright/tests/experimental/README.md +++ b/superset-frontend/playwright/tests/experimental/README.md @@ -19,52 +19,98 @@ under the License. # Experimental Playwright Tests -This directory contains Playwright tests that are still under development or validation. - ## Purpose -Tests in this directory run in "shadow mode" with `continue-on-error: true` in CI: -- Failures do NOT block PR merges -- Allows tests to run in CI to validate stability before promotion -- Provides visibility into test reliability over time +This directory contains **experimental** Playwright E2E tests that are being developed and stabilized before becoming part of the required test suite. -## Promoting Tests to Stable +## How Experimental Tests Work -Once a test has proven stable (no false positives/negatives over sufficient time): - -1. Move the test file out of `experimental/` to the appropriate feature directory: - ```bash - # From the repository root: - git mv superset-frontend/playwright/tests/experimental/dashboard/test.spec.ts \ - superset-frontend/playwright/tests/dashboard/ - - # Or from the superset-frontend/ directory: - git mv playwright/tests/experimental/dashboard/test.spec.ts \ - playwright/tests/dashboard/ - ``` - -2. The test will automatically become required for merge - -## Test Organization - -Organize tests by feature area: -- `auth/` - Authentication and authorization tests -- `dashboard/` - Dashboard functionality tests -- `explore/` - Chart builder tests -- `sqllab/` - SQL Lab tests -- etc. - -## Running Tests +### Running Tests +**By default (CI and local), experimental tests are EXCLUDED:** ```bash -# Run all experimental tests (requires INCLUDE_EXPERIMENTAL env var) -INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/ - -# Run specific experimental test -INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/dashboard/test.spec.ts - -# Run in UI mode for debugging -INCLUDE_EXPERIMENTAL=true npm run playwright:ui -- experimental/ +npm run playwright:test +# Only runs stable tests (tests/auth/*) ``` -**Note**: The `INCLUDE_EXPERIMENTAL=true` environment variable is required because experimental tests are filtered out by default in `playwright.config.ts`. Without it, Playwright will report "No tests found". +**To include experimental tests, set the environment variable:** +```bash +INCLUDE_EXPERIMENTAL=true npm run playwright:test +# Runs all tests including experimental/ +``` + +### CI Behavior + +- **Required CI jobs**: Experimental tests are excluded by default + - Tests in `experimental/` do NOT block merges + - Failures in `experimental/` do NOT fail the build + +- **Experimental CI jobs** (optional): Use `TEST_PATH=experimental/` + - Set `INCLUDE_EXPERIMENTAL=true` in the job environment to include experimental tests + - These jobs can use `continue-on-error: true` for shadow mode + +### Configuration + +The experimental pattern is configured in `playwright.config.ts`: + +```typescript +testIgnore: process.env.INCLUDE_EXPERIMENTAL + ? undefined + : '**/experimental/**', +``` + +This ensures: +- Without `INCLUDE_EXPERIMENTAL`: Tests in `experimental/` are ignored +- With `INCLUDE_EXPERIMENTAL=true`: All tests run, including experimental + +## When to Use Experimental + +Add tests to `experimental/` when: + +1. **Testing new infrastructure** - New page objects, components, or patterns that need real-world validation +2. **Flaky tests** - Tests that pass locally but have intermittent CI failures that need investigation +3. **New test types** - E2E tests for new features that need to prove stability before becoming required +4. **Prototyping** - Experimental approaches that may or may not become standard patterns + +## Moving Tests to Stable + +Once an experimental test has proven stable (consistent CI passes over time): + +1. **Move the test file** from `experimental/` to the appropriate stable directory: + ```bash + git mv tests/experimental/dataset/my-test.spec.ts tests/dataset/my-test.spec.ts + ``` + +2. **Commit the move** with a clear message: + ```bash + git commit -m "test(playwright): promote my-test from experimental to stable" + ``` + +3. **Test will now be required** - It will run by default and block merges on failure + +## Current Experimental Tests + +### Dataset Tests + +- **`dataset/dataset-list.spec.ts`** - Dataset list E2E tests + - Status: Infrastructure complete, validating stability + - Includes: Delete dataset test with API-based test data + - Supporting infrastructure: API helpers, Modal components, page objects + +## Infrastructure Location + +**Important**: Supporting infrastructure (components, page objects, API helpers) should live in **stable locations**, NOT under `experimental/`: + +✅ **Correct locations:** +- `playwright/components/` - Components used by any tests +- `playwright/pages/` - Page objects for any features +- `playwright/helpers/api/` - API helpers for test data setup + +❌ **Avoid:** +- `playwright/tests/experimental/components/` - Makes it hard to share infrastructure + +This keeps infrastructure reusable and avoids duplication when tests graduate from experimental to stable. + +## Questions? + +See [Superset Testing Documentation](https://superset.apache.org/docs/contributing/development#testing) or ask in the `#testing` Slack channel. diff --git a/superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts b/superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts new file mode 100644 index 00000000000..0eb9cc9d88f --- /dev/null +++ b/superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts @@ -0,0 +1,254 @@ +/** + * 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 '@playwright/test'; +import { DatasetListPage } from '../../../pages/DatasetListPage'; +import { ExplorePage } from '../../../pages/ExplorePage'; +import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal'; +import { DuplicateDatasetModal } from '../../../components/modals/DuplicateDatasetModal'; +import { Toast } from '../../../components/core/Toast'; +import { + apiDeleteDataset, + apiGetDataset, + getDatasetByName, + ENDPOINTS, +} from '../../../helpers/api/dataset'; + +/** + * Test data constants + * These reference example datasets loaded via --load-examples in CI. + * + * DEPENDENCY: Tests assume the example dataset exists and is a virtual dataset. + * If examples aren't loaded or the dataset changes, tests will fail. + * This is acceptable for experimental tests; stable tests should use dedicated + * seeded test data to decouple from example data changes. + */ +const TEST_DATASETS = { + EXAMPLE_DATASET: 'members_channels_2', +} as const; + +/** + * Dataset List E2E Tests + * + * Uses flat test() structure per project convention (matches login.spec.ts). + * Shared state and hooks are at file scope. + */ + +// File-scope state (reset in beforeEach) +let datasetListPage: DatasetListPage; +let explorePage: ExplorePage; +let testResources: { datasetIds: number[] } = { datasetIds: [] }; + +test.beforeEach(async ({ page }) => { + datasetListPage = new DatasetListPage(page); + explorePage = new ExplorePage(page); + testResources = { datasetIds: [] }; // Reset for each test + + // Navigate to dataset list page + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); +}); + +test.afterEach(async ({ page }) => { + // Cleanup any resources created during the test + const promises = []; + for (const datasetId of testResources.datasetIds) { + promises.push( + apiDeleteDataset(page, datasetId, { + failOnStatusCode: false, + }).catch(error => { + // Log cleanup failures to avoid silent resource leaks + console.warn( + `[Cleanup] Failed to delete dataset ${datasetId}:`, + String(error), + ); + }), + ); + } + await Promise.all(promises); +}); + +test('should navigate to Explore when dataset name is clicked', async ({ + page, +}) => { + // Use existing example dataset (hermetic - loaded in CI via --load-examples) + const datasetName = TEST_DATASETS.EXAMPLE_DATASET; + const dataset = await getDatasetByName(page, datasetName); + expect(dataset).not.toBeNull(); + + // Verify dataset is visible in list (uses page object + Playwright auto-wait) + await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible(); + + // Click on dataset name to navigate to Explore + await datasetListPage.clickDatasetName(datasetName); + + // Wait for Explore page to load (validates URL + datasource control) + await explorePage.waitForPageLoad(); + + // Verify correct dataset is loaded in datasource control + const loadedDatasetName = await explorePage.getDatasetName(); + expect(loadedDatasetName).toContain(datasetName); + + // Verify visualization switcher shows default viz type (indicates full page load) + await expect(explorePage.getVizSwitcher()).toBeVisible(); + await expect(explorePage.getVizSwitcher()).toContainText('Table'); +}); + +test('should delete a dataset with confirmation', async ({ page }) => { + // Get example dataset to duplicate + const originalName = TEST_DATASETS.EXAMPLE_DATASET; + const originalDataset = await getDatasetByName(page, originalName); + expect(originalDataset).not.toBeNull(); + + // Create throwaway copy for deletion (hermetic - uses UI duplication) + const datasetName = `test_delete_${Date.now()}`; + + // Verify original dataset is visible in list + await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible(); + + // Set up response intercept to capture duplicate dataset ID + const duplicateResponsePromise = page.waitForResponse( + response => + response.url().includes(`${ENDPOINTS.DATASET}duplicate`) && + response.status() === 201, + ); + + // Click duplicate action button + await datasetListPage.clickDuplicateAction(originalName); + + // Duplicate modal should appear and be ready for interaction + const duplicateModal = new DuplicateDatasetModal(page); + await duplicateModal.waitForReady(); + + // Fill in new dataset name + await duplicateModal.fillDatasetName(datasetName); + + // Click the Duplicate button + await duplicateModal.clickDuplicate(); + + // Get the duplicate dataset ID from response and track immediately + const duplicateResponse = await duplicateResponsePromise; + const duplicateData = await duplicateResponse.json(); + const duplicateId = duplicateData.id; + + // Track duplicate for cleanup immediately (before any operations that could fail) + testResources = { datasetIds: [duplicateId] }; + + // Modal should close + await duplicateModal.waitForHidden(); + + // Refresh page to see new dataset + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + + // Verify dataset is visible in list + await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible(); + + // Click delete action button + await datasetListPage.clickDeleteAction(datasetName); + + // 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 with correct message + const toast = new Toast(page); + const successToast = toast.getSuccess(); + await expect(successToast).toBeVisible(); + await expect(toast.getMessage()).toContainText('Deleted'); + + // Verify dataset is removed from list + await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible(); +}); + +test('should duplicate a dataset with new name', async ({ page }) => { + // Use virtual example dataset + const originalName = TEST_DATASETS.EXAMPLE_DATASET; + const duplicateName = `duplicate_${originalName}_${Date.now()}`; + + // Get the dataset by name (ID varies by environment) + const original = await getDatasetByName(page, originalName); + expect(original).not.toBeNull(); + expect(original!.id).toBeGreaterThan(0); + + // Verify original dataset is visible in list + await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible(); + + // Set up response intercept to capture duplicate dataset ID + const duplicateResponsePromise = page.waitForResponse( + response => + response.url().includes(`${ENDPOINTS.DATASET}duplicate`) && + response.status() === 201, + ); + + // Click duplicate action button + await datasetListPage.clickDuplicateAction(originalName); + + // Duplicate modal should appear and be ready for interaction + const duplicateModal = new DuplicateDatasetModal(page); + await duplicateModal.waitForReady(); + + // Fill in new dataset name + await duplicateModal.fillDatasetName(duplicateName); + + // Click the Duplicate button + await duplicateModal.clickDuplicate(); + + // Get the duplicate dataset ID from response + const duplicateResponse = await duplicateResponsePromise; + const duplicateData = await duplicateResponse.json(); + const duplicateId = duplicateData.id; + + // Track duplicate for cleanup (original is example data, don't delete it) + testResources = { datasetIds: [duplicateId] }; + + // Modal should close + await duplicateModal.waitForHidden(); + + // Note: Duplicate action does not show a success toast (only errors) + // Verification is done via API and UI list check below + + // Refresh to see the duplicated dataset + await datasetListPage.goto(); + await datasetListPage.waitForTableLoad(); + + // Verify both datasets exist in list + await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible(); + await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible(); + + // API Verification: Compare original and duplicate datasets + const duplicateResponseData = await apiGetDataset(page, duplicateId); + const duplicateDataFull = await duplicateResponseData.json(); + + // Verify key properties were copied correctly (original data already fetched) + expect(duplicateDataFull.result.sql).toBe(original!.sql); + expect(duplicateDataFull.result.database.id).toBe(original!.database.id); + expect(duplicateDataFull.result.schema).toBe(original!.schema); + // Name should be different (the duplicate name) + expect(duplicateDataFull.result.table_name).toBe(duplicateName); +}); diff --git a/superset-frontend/playwright/utils/constants.ts b/superset-frontend/playwright/utils/constants.ts new file mode 100644 index 00000000000..c9199c2f6d5 --- /dev/null +++ b/superset-frontend/playwright/utils/constants.ts @@ -0,0 +1,46 @@ +/** + * 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. + */ + +/** + * Timeout constants for Playwright tests. + * Only define timeouts that differ from Playwright defaults or are semantically important. + * + * Default Playwright timeouts (from playwright.config.ts): + * - Test timeout: 30000ms (30s) + * - Expect timeout: 8000ms (8s) + * + * Use these constants instead of magic numbers for better maintainability. + */ + +export const TIMEOUT = { + /** + * Global setup timeout (matches test timeout for cold CI starts) + */ + GLOBAL_SETUP: 30000, // 30s for global setup auth + + /** + * Page navigation and load timeouts + */ + PAGE_LOAD: 10000, // 10s for page transitions (login → welcome, dataset → explore) + + /** + * Form and UI element load timeouts + */ + FORM_LOAD: 5000, // 5s for forms to become visible (login form, modals) +} as const; diff --git a/superset-frontend/playwright/utils/urls.ts b/superset-frontend/playwright/utils/urls.ts index 67b9e466f35..f3578de6fe3 100644 --- a/superset-frontend/playwright/utils/urls.ts +++ b/superset-frontend/playwright/utils/urls.ts @@ -17,7 +17,18 @@ * under the License. */ +/** + * URL constants for Playwright navigation + * + * These are relative paths (no leading '/') that rely on baseURL ending with '/'. + * playwright.config.ts normalizes baseURL to always end with '/' to ensure + * correct URL resolution with APP_PREFIX (e.g., /app/prefix/). + * + * Example: baseURL='http://localhost:8088/app/prefix/' + 'tablemodelview/list' + * = 'http://localhost:8088/app/prefix/tablemodelview/list' + */ export const URL = { + DATASET_LIST: 'tablemodelview/list', LOGIN: 'login/', WELCOME: 'superset/welcome/', } as const;