diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js b/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js deleted file mode 100644 index 4b24c844364..00000000000 --- a/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * 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 { selectResultsTab } from './sqllab.helper'; - -describe.skip('SqlLab datasource panel', () => { - beforeEach(() => { - cy.visit('/sqllab'); - }); - - // TODO the test below is flaky, and has been disabled for the time being - // (notice the `it.skip`) - it('creates a table preview when a database, schema, and table are selected', () => { - cy.intercept('**/superset/table/**').as('tableMetadata'); - - // it should have dropdowns to select database, schema, and table - cy.get('.sql-toolbar .Select').should('have.length', 3); - - cy.get('.sql-toolbar .table-schema').should('not.exist'); - cy.get('[data-test="filterable-table-container"]').should('not.exist'); - - cy.get('.sql-toolbar .Select') - .eq(0) // database select - .within(() => { - // note: we have to set force: true because the input is invisible / cypress throws - cy.get('input').type('main{enter}', { force: true }); - }); - - cy.get('.sql-toolbar .Select') - .eq(1) // schema select - .within(() => { - cy.get('input').type('main{enter}', { force: true }); - }); - - cy.get('.sql-toolbar .Select') - .eq(2) // table select - .within(() => { - cy.get('input').type('birth_names{enter}', { force: true }); - }); - - cy.wait('@tableMetadata'); - - cy.get('.sql-toolbar .table-schema').should('have.length', 1); - selectResultsTab().should('have.length', 1); - - // add another table and check for added schema + preview - cy.get('.sql-toolbar .Select') - .eq(2) - .within(() => { - cy.get('input').type('logs{enter}', { force: true }); - }); - - cy.wait('@tableMetadata'); - - cy.get('.sql-toolbar .table-schema').should('have.length', 2); - selectResultsTab().should('have.length', 2); - }); -}); diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts b/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts deleted file mode 100644 index 389698dcb8d..00000000000 --- a/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * 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 { nanoid } from 'nanoid'; -import { selectResultsTab, assertSQLLabResultsAreEqual } from './sqllab.helper'; - -function parseClockStr(node: JQuery) { - return Number.parseFloat(node.text().replace(/:/g, '')); -} - -describe('SqlLab query panel', () => { - beforeEach(() => { - cy.visit('/sqllab'); - }); - - it.skip('supports entering and running a query', () => { - // row limit has to be < ~10 for us to be able to determine how many rows - // are fetched below (because React _Virtualized_ does not render all rows) - let clockTime = 0; - - cy.intercept({ - method: 'POST', - url: '**/api/v1/sqllab/execute/', - }).as('mockSQLResponse'); - - cy.get('.TableSelector .Select:eq(0)').click(); - cy.get('.TableSelector .Select:eq(0) input[type=text]').focus(); - cy.focused().type('{enter}'); - - cy.get('#brace-editor textarea').focus(); - cy.focused().clear(); - cy.focused().type(`{selectall}{backspace}SELECT 1`); - - cy.get('#js-sql-toolbar button:eq(0)').eq(0).click(); - - // wait for 300 milliseconds - cy.wait(300); - - // started timer - cy.get('.sql-toolbar .label-success').then(node => { - clockTime = parseClockStr(node); - // should be longer than 0.2s - expect(clockTime).greaterThan(0.2); - }); - - cy.wait('@mockSQLResponse'); - - // timer is increasing - cy.get('.sql-toolbar .label-success').then(node => { - const newClockTime = parseClockStr(node); - expect(newClockTime).greaterThan(0.9); - clockTime = newClockTime; - }); - - // rerun the query - cy.get('#js-sql-toolbar button:eq(0)').eq(0).click(); - - // should restart the timer - cy.get('.sql-toolbar .label-success').contains('00:00:00'); - cy.wait('@mockSQLResponse'); - cy.get('.sql-toolbar .label-success').then(node => { - expect(parseClockStr(node)).greaterThan(0.9); - }); - }); - - it.skip('successfully saves a query', () => { - cy.intercept('**/api/v1/database/**/tables/**').as('getTables'); - - const query = - 'SELECT ds, gender, name, num FROM main.birth_names ORDER BY name LIMIT 3'; - const savedQueryTitle = `CYPRESS TEST QUERY ${nanoid()}`; - - // we will assert that the results of the query we save, and the saved query are the same - let initialResultsTable: HTMLElement | null = null; - let savedQueryResultsTable = null; - - cy.get('#brace-editor textarea').clear({ force: true }); - cy.get('#brace-editor textarea').type(`{selectall}{backspace}${query}`, { - force: true, - }); - cy.get('#brace-editor textarea').focus(); // focus => blur is required for updating the query that is to be saved - cy.focused().blur(); - - // ctrl + r also runs query - cy.get('#brace-editor textarea').type('{ctrl}r', { force: true }); - - cy.wait('@sqlLabQuery'); - - // Save results to check against below - selectResultsTab().then(resultsA => { - initialResultsTable = resultsA[0]; - }); - - cy.get('#js-sql-toolbar button') - .eq(1) // save query - .click(); - - // Enter name + save into modal - cy.get('.modal-sm input').clear({ force: true }); - cy.get('.modal-sm input').type(`{selectall}{backspace}${savedQueryTitle}`, { - force: true, - }); - - cy.get('.modal-sm .modal-body button') - .eq(0) // save - .click(); - - // first row contains most recent link, follow back to SqlLab - cy.get('table tr:first-child a[href*="savedQueryId"').click(); - - // will timeout without explicitly waiting here - cy.wait(['@getSavedQuery', '@getTables']); - - // run the saved query - cy.get('#js-sql-toolbar button') - .eq(0) // run query - .click(); - - cy.wait('@sqlLabQuery'); - - // assert the results of the saved query match the initial results - selectResultsTab().then(resultsB => { - savedQueryResultsTable = resultsB[0]; - - assertSQLLabResultsAreEqual(initialResultsTable, savedQueryResultsTable); - }); - }); - - it.skip('Create a chart from a query', () => { - cy.intercept('**/api/v1/sqllab/execute/').as('queryFinished'); - cy.intercept('**/api/v1/explore/**').as('explore'); - cy.intercept('**/api/v1/chart/**').as('chart'); - cy.intercept('**/tabstateview/**').as('tabstateview'); - - // cypress doesn't handle opening a new tab, override window.open to open in the same tab - cy.window().then(win => { - cy.stub(win, 'open', url => { - // eslint-disable-next-line no-param-reassign - win.location.href = url; - }); - }); - cy.wait('@tabstateview'); - - const query = 'SELECT gender, name FROM birth_names'; - - cy.get('.ace_text-input').focus(); - cy.focused().clear({ force: true }); - cy.focused().type(`{selectall}{backspace}${query}`, { force: true }); - cy.get('.sql-toolbar button').contains('Run').click(); - cy.wait('@queryFinished'); - - cy.get( - '.SouthPane .ant-tabs-content > .ant-tabs-tabpane-active > div button:first', - { timeout: 10000 }, - ).click(); - - cy.wait('@explore'); - cy.get('[data-test="datasource-control"] .title-select').contains(query); - cy.get('.column-option-label').first().contains('gender'); - cy.get('.column-option-label').last().contains('name'); - - cy.get( - '[data-test="all_columns"] [data-test="dnd-labels-container"] > div:first-child', - ).contains('gender'); - cy.get( - '[data-test="all_columns"] [data-test="dnd-labels-container"] > div:nth-child(2)', - ).contains('name'); - - cy.wait('@chart'); - cy.get('[data-test="slice-container"] table > thead th') - .first() - .contains('gender'); - cy.get('[data-test="slice-container"] table > thead th') - .last() - .contains('name'); - }); -}); diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/sqllab.helper.js b/superset-frontend/cypress-base/cypress/e2e/sqllab/sqllab.helper.js deleted file mode 100644 index 45f298205d3..00000000000 --- a/superset-frontend/cypress-base/cypress/e2e/sqllab/sqllab.helper.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * 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. - */ -export const selectResultsTab = () => - cy.get('.SouthPane .ReactVirtualized__Table', { timeout: 10000 }); - -// this function asserts that the result set for two SQL lab table results are equal -export const assertSQLLabResultsAreEqual = (resultsA, resultsB) => { - const [headerA, bodyWrapperA] = resultsA.childNodes; - const bodyA = bodyWrapperA.childNodes[0]; - - const [headerB, bodyWrapperB] = resultsB.childNodes; - const bodyB = bodyWrapperB.childNodes[0]; - - expect(headerA.childNodes.length).to.equal(headerB.childNodes.length); - expect(bodyA.childNodes.length).to.equal(bodyB.childNodes.length); - - bodyA.childNodes.forEach((rowA, rowIndex) => { - const rowB = bodyB.childNodes[rowIndex]; - - rowA.childNodes.forEach((cellA, columnIndex) => { - const cellB = rowB.childNodes[columnIndex]; - expect(cellA.innerText).to.equal(cellB.innerText); - }); - }); -}; diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts b/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts deleted file mode 100644 index 8ca480aab6d..00000000000 --- a/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * 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. - */ -describe('SqlLab query tabs', () => { - beforeEach(() => { - cy.visit('/sqllab'); - }); - - const tablistSelector = '[data-test="sql-editor-tabs"] > [role="tablist"]'; - const tabSelector = `${tablistSelector} [role="tab"]:not([type="button"])`; - - it('allows you to create and close a tab', () => { - cy.get(tabSelector).then(tabs => { - const initialTabCount = tabs.length; - const initialUntitledCount = Math.max( - 0, - ...tabs - .map( - (i, tabItem) => - Number(tabItem.textContent?.match(/Untitled Query (\d+)/)?.[1]) || - 0, - ) - .toArray(), - ); - - // add two new tabs - cy.get('[data-test="add-tab-icon"]:visible:last').click({ force: true }); - cy.contains('[role="tab"]', `Untitled Query ${initialUntitledCount + 1}`); - cy.get(tabSelector).should('have.length', initialTabCount + 1); - - cy.get('[data-test="add-tab-icon"]:visible:last').click({ force: true }); - cy.contains('[role="tab"]', `Untitled Query ${initialUntitledCount + 2}`); - cy.get(tabSelector).should('have.length', initialTabCount + 2); - - // close the tabs - cy.get(`${tabSelector}:last [data-test="dropdown-trigger"]`).click({ - force: true, - }); - cy.get('[data-test="close-tab-menu-option"]').click(); - cy.get(tabSelector).should('have.length', initialTabCount + 1); - cy.contains('[role="tab"]', `Untitled Query ${initialUntitledCount + 1}`); - - cy.get(`${tablistSelector} [aria-label="remove"]:last`).click(); - cy.get(tabSelector).should('have.length', initialTabCount); - }); - }); - - it('opens a new tab by a button and a shortcut', () => { - const editorContent = '.ace_editor .ace_content'; - const editorInput = '.ace_editor textarea'; - const queryLimitSelector = '#js-sql-toolbar .limitDropdown'; - cy.get(tabSelector).then(tabs => { - const initialTabCount = tabs.length; - const initialUntitledCount = Math.max( - 0, - ...tabs - .map( - (i, tabItem) => - Number(tabItem.textContent?.match(/Untitled Query (\d+)/)?.[1]) || - 0, - ) - .toArray(), - ); - - // configure some editor settings - cy.get(editorInput).type('some random query string', { force: true }); - cy.get(queryLimitSelector).parent().click({ force: true }); - cy.get('.ant-dropdown-menu') - .last() - .find('.ant-dropdown-menu-item') - .first() - .click({ force: true }); - - // open a new tab by a button - cy.get('[data-test="add-tab-icon"]:visible:last').click({ force: true }); - cy.contains('[role="tab"]', `Untitled Query ${initialUntitledCount + 1}`); - cy.get(tabSelector).should('have.length', initialTabCount + 1); - cy.get(editorContent).contains('SELECT ...'); - cy.get(queryLimitSelector).contains('10'); - - // close the tab - cy.get(`${tabSelector}:last [data-test="dropdown-trigger"]`).click({ - force: true, - }); - cy.get(`${tablistSelector} [aria-label="remove"]:last`).click({ - force: true, - }); - cy.get(tabSelector).should('have.length', initialTabCount); - - // open a new tab by a shortcut - cy.get('body').type('{ctrl}t'); - cy.get(tabSelector).should('have.length', initialTabCount + 1); - cy.contains('[role="tab"]', `Untitled Query ${initialUntitledCount + 1}`); - cy.get(editorContent).contains('SELECT ...'); - cy.get(queryLimitSelector).contains('10'); - }); - }); -}); diff --git a/superset-frontend/playwright.config.ts b/superset-frontend/playwright.config.ts index 2c001297fe5..9edef2176c8 100644 --- a/superset-frontend/playwright.config.ts +++ b/superset-frontend/playwright.config.ts @@ -94,6 +94,7 @@ export default defineConfig({ name: 'chromium', testIgnore: [ '**/tests/auth/**/*.spec.ts', + '**/tests/sqllab/**/*.spec.ts', ...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']), ], use: { @@ -103,6 +104,22 @@ export default defineConfig({ storageState: 'playwright/.auth/user.json', }, }, + { + // SQL Lab needs its own project because tab state is stored server-side + // per user (/tabstateview/*). All workers share the same auth user, so + // parallel workers mutating tabs would cause nondeterministic tab counts + // and cross-worker tab deletions. Other test suites (dataset, dashboard, + // chart) don't need this because they create/delete isolated resources + // via API with unique names — no shared mutable state between tests. + name: 'chromium-sqllab', + testMatch: '**/tests/sqllab/**/*.spec.ts', + fullyParallel: false, + use: { + browserName: 'chromium', + testIdAttribute: 'data-test', + storageState: 'playwright/.auth/user.json', + }, + }, { // Separate project for unauthenticated tests (login, signup, etc.) // These tests use beforeEach for per-test navigation - no global auth diff --git a/superset-frontend/playwright/components/core/AgGrid.ts b/superset-frontend/playwright/components/core/AgGrid.ts new file mode 100644 index 00000000000..0c073222dfb --- /dev/null +++ b/superset-frontend/playwright/components/core/AgGrid.ts @@ -0,0 +1,85 @@ +/** + * 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 AG_GRID_SELECTORS = { + ROOT: '[role="grid"]', + HEADER_ROW: '.ag-header-row', + HEADER_CELL: '.ag-header-cell', + BODY_ROW: '.ag-row', + CELL: '.ag-cell', +} as const; + +/** + * AG Grid component wrapper for Playwright. + * Used by FilterableTable/GridTable in SQL Lab results and elsewhere. + */ +export class AgGrid { + readonly page: Page; + private readonly locator: Locator; + + constructor(page: Page, locator: Locator) { + this.page = page; + this.locator = locator; + } + + get element(): Locator { + return this.locator; + } + + /** + * Wait for the grid to render with data rows + */ + async waitForRows(options?: { timeout?: number }): Promise { + await this.locator + .locator(AG_GRID_SELECTORS.BODY_ROW) + .first() + .waitFor({ state: 'visible', ...options }); + } + + /** + * Get header cell texts + */ + async getHeaderTexts(): Promise { + return this.locator + .locator(AG_GRID_SELECTORS.HEADER_CELL) + .allTextContents(); + } + + /** + * Get the number of visible data rows + */ + async getRowCount(): Promise { + return this.locator.locator(AG_GRID_SELECTORS.BODY_ROW).count(); + } + + /** + * Get cell text at a specific row and column index (0-based) + */ + async getCellText(row: number, col: number): Promise { + const text = await this.locator + .locator(AG_GRID_SELECTORS.BODY_ROW) + .nth(row) + .locator(AG_GRID_SELECTORS.CELL) + .nth(col) + .textContent(); + return text?.trim() ?? ''; + } +} diff --git a/superset-frontend/playwright/components/core/EditableTabs.ts b/superset-frontend/playwright/components/core/EditableTabs.ts new file mode 100644 index 00000000000..ca68181b498 --- /dev/null +++ b/superset-frontend/playwright/components/core/EditableTabs.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. + */ + +import { Tabs } from './Tabs'; + +/** + * EditableTabs component for Ant Design editable-card tabs. + * + * Mirrors the Superset EditableTabs component (type="editable-card") + * which adds add/remove tab functionality to the base Tabs component. + * + * The add button (.ant-tabs-nav-add) is only rendered when + * type="editable-card". If the host component switches to type="card" + * (e.g., SQL Lab empty state), use the host page object for that case. + */ +export class EditableTabs extends Tabs { + /** + * Clicks the add-tab button rendered by antd in editable-card mode. + */ + async addTab(): Promise { + await this.element.getByRole('button', { name: 'Add tab' }).click(); + } + + /** + * Clicks the remove button on the last tab. + */ + async removeLastTab(): Promise { + await this.nav.locator('.ant-tabs-tab-remove').last().click(); + } +} diff --git a/superset-frontend/playwright/components/core/Modal.ts b/superset-frontend/playwright/components/core/Modal.ts index 2b91369113c..f90e37a238f 100644 --- a/superset-frontend/playwright/components/core/Modal.ts +++ b/superset-frontend/playwright/components/core/Modal.ts @@ -68,7 +68,7 @@ export class Modal { } /** - * Gets a footer button by text content (private helper) + * Gets a footer button by text content * @param buttonText - The text content of the button */ private getFooterButton(buttonText: string): Locator { @@ -80,7 +80,7 @@ export class Modal { * @param buttonText - The text content of the button to click * @param options - Optional click options */ - protected async clickFooterButton( + async clickFooterButton( buttonText: string, options?: { timeout?: number; force?: boolean; delay?: number }, ): Promise { diff --git a/superset-frontend/playwright/components/core/Popover.ts b/superset-frontend/playwright/components/core/Popover.ts new file mode 100644 index 00000000000..3912286edb0 --- /dev/null +++ b/superset-frontend/playwright/components/core/Popover.ts @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Locator, Page } from '@playwright/test'; +import { Button } from './Button'; + +/** + * Ant Design Popover component. + */ +export class Popover { + readonly page: Page; + private readonly locator: Locator; + + constructor(page: Page, locator?: Locator) { + this.page = page; + this.locator = locator ?? page.locator('.ant-popover-content'); + } + + get element(): Locator { + return this.locator; + } + + async waitForVisible(options?: { timeout?: number }): Promise { + await this.locator.waitFor({ state: 'visible', ...options }); + } + + async waitForHidden(options?: { timeout?: number }): Promise { + await this.locator.waitFor({ state: 'hidden', ...options }); + } + + getButton(name: string): Button { + return new Button( + this.page, + this.locator.getByRole('button', { name, exact: true }), + ); + } +} diff --git a/superset-frontend/playwright/components/core/Select.ts b/superset-frontend/playwright/components/core/Select.ts index 1fb9191bcf5..5472b332636 100644 --- a/superset-frontend/playwright/components/core/Select.ts +++ b/superset-frontend/playwright/components/core/Select.ts @@ -18,6 +18,7 @@ */ import { Locator, Page } from '@playwright/test'; +import { TIMEOUT } from '../../utils/constants'; /** * Ant Design Select component selectors @@ -87,7 +88,7 @@ export class Select { await this.page .locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`) .last() - .waitFor({ state: 'hidden', timeout: 5000 }) + .waitFor({ state: 'hidden', timeout: TIMEOUT.UI_TRANSITION }) .catch(error => { // Only ignore TimeoutError (dropdown may already be closed); re-throw others if (!(error instanceof Error) || error.name !== 'TimeoutError') { diff --git a/superset-frontend/playwright/components/core/Tabs.ts b/superset-frontend/playwright/components/core/Tabs.ts index cc4b7f50053..868bf728bdb 100644 --- a/superset-frontend/playwright/components/core/Tabs.ts +++ b/superset-frontend/playwright/components/core/Tabs.ts @@ -21,6 +21,10 @@ import { Locator, Page } from '@playwright/test'; /** * Tabs component for Ant Design tab navigation. + * + * Expects the locator to point to the `.ant-tabs` wrapper element + * (not the inner tablist) so that `nav` can scope to the outer tab bar + * and exclude nested/inner tabs (e.g. Results / Query history in SQL Lab). */ export class Tabs { readonly page: Page; @@ -28,23 +32,54 @@ export class Tabs { constructor(page: Page, locator?: Locator) { this.page = page; - // Default to the tablist role if no specific locator provided - this.locator = locator ?? page.getByRole('tablist'); + this.locator = locator ?? page.locator('.ant-tabs').first(); } /** - * Gets the tablist element locator + * The root element locator for this tabs component. */ get element(): Locator { return this.locator; } /** - * Gets a tab by name, scoped to this tablist's container + * The tab navigation bar for this component. + * Scoped to the first `.ant-tabs-nav` descendant so that queries + * only hit this component's tabs, never nested/inner tab bars. + */ + protected get nav(): Locator { + return this.locator.locator('.ant-tabs-nav').first(); + } + + /** + * Returns the number of tabs. + * Counts `.ant-tabs-tab` wrappers in the nav bar — one per physical tab, + * regardless of inner role="tab" elements (btn, remove button, etc.). + */ + async getTabCount(): Promise { + return this.nav.locator('.ant-tabs-tab').count(); + } + + /** + * Returns the text content of all tabs. + */ + async getTabNames(): Promise { + return this.nav.locator('.ant-tabs-tab-btn').allTextContents(); + } + + /** + * Gets a tab button by name, scoped to this component's nav bar. + * Anchored at start (^) with negative lookahead (?!\d) to prevent + * partial matches: "Query" won't match "Query 1", and "Query 1" + * won't match "Query 10". Trailing icon text (e.g. " circle-solid") + * is allowed since (?!\d) permits non-digit suffixes. * @param tabName - The name/label of the tab */ getTab(tabName: string): Locator { - return this.locator.getByRole('tab', { name: tabName }); + const escaped = tabName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return this.nav + .locator('.ant-tabs-tab-btn') + .filter({ hasText: new RegExp(`^${escaped}(?!\\d)`) }); } /** @@ -63,6 +98,16 @@ export class Tabs { return this.page.getByRole('tabpanel', { name: tabName }); } + /** + * Returns the name of the currently active tab. + */ + async getActiveTabName(): Promise { + const text = await this.nav + .locator('.ant-tabs-tab-active .ant-tabs-tab-btn') + .textContent(); + return text?.trim() ?? ''; + } + /** * Checks if a tab is selected * @param tabName - The name/label of the tab diff --git a/superset-frontend/playwright/components/core/index.ts b/superset-frontend/playwright/components/core/index.ts index 53a2ad71d6e..a16251c6fb6 100644 --- a/superset-frontend/playwright/components/core/index.ts +++ b/superset-frontend/playwright/components/core/index.ts @@ -19,12 +19,15 @@ // Core Playwright Components for Superset export { AceEditor } from './AceEditor'; +export { AgGrid } from './AgGrid'; export { Button } from './Button'; export { Checkbox } from './Checkbox'; +export { EditableTabs } from './EditableTabs'; export { Form } from './Form'; export { Input } from './Input'; export { Menu } from './Menu'; export { Modal } from './Modal'; +export { Popover } from './Popover'; export { Select } from './Select'; export { Table } from './Table'; export { Tabs } from './Tabs'; diff --git a/superset-frontend/playwright/components/modals/EditDatasetModal.ts b/superset-frontend/playwright/components/modals/EditDatasetModal.ts index bce66be9625..06f29d600a8 100644 --- a/superset-frontend/playwright/components/modals/EditDatasetModal.ts +++ b/superset-frontend/playwright/components/modals/EditDatasetModal.ts @@ -40,8 +40,11 @@ export class EditDatasetModal extends Modal { // 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')); + // Scope tabs to modal dialog so nav getter finds .ant-tabs-nav as descendant + this.tabs = new Tabs( + page, + this.specificLocator.locator('.ant-tabs').first(), + ); } /** diff --git a/superset-frontend/playwright/components/modals/SaveDatasetModal.ts b/superset-frontend/playwright/components/modals/SaveDatasetModal.ts new file mode 100644 index 00000000000..00cf4872166 --- /dev/null +++ b/superset-frontend/playwright/components/modals/SaveDatasetModal.ts @@ -0,0 +1,43 @@ +/** + * 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 { Input, Modal } from '../core'; + +/** + * Save Dataset modal in SQL Lab. + * Appears when clicking "Save dataset" after running a query. + */ +export class SaveDatasetModal extends Modal { + constructor(page: Page) { + super(page, '[data-test="Save or Overwrite Dataset-modal"] .ant-modal'); + } + + private get nameInput(): Input { + return new Input( + this.page, + this.body.locator('input[placeholder="Dataset name"]'), + ); + } + + async fillName(name: string): Promise { + await this.nameInput.clear(); + await this.nameInput.fill(name); + } +} diff --git a/superset-frontend/playwright/components/modals/SaveQueryModal.ts b/superset-frontend/playwright/components/modals/SaveQueryModal.ts new file mode 100644 index 00000000000..546c82b9fa2 --- /dev/null +++ b/superset-frontend/playwright/components/modals/SaveQueryModal.ts @@ -0,0 +1,43 @@ +/** + * 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 { Input, Modal } from '../core'; + +/** + * Save Query modal in SQL Lab. + * Appears when clicking the Save button in the SQL editor toolbar. + */ +export class SaveQueryModal extends Modal { + constructor(page: Page) { + super(page, '.save-query-modal'); + } + + private get nameInput(): Input { + return new Input( + this.page, + this.body.locator('input[type="text"]').first(), + ); + } + + async fillName(name: string): Promise { + await this.nameInput.clear(); + await this.nameInput.fill(name); + } +} diff --git a/superset-frontend/playwright/components/modals/index.ts b/superset-frontend/playwright/components/modals/index.ts index 41074401a7d..94bbe08def6 100644 --- a/superset-frontend/playwright/components/modals/index.ts +++ b/superset-frontend/playwright/components/modals/index.ts @@ -24,3 +24,5 @@ export { DeleteConfirmationModal } from './DeleteConfirmationModal'; export { DuplicateDatasetModal } from './DuplicateDatasetModal'; export { EditDatasetModal } from './EditDatasetModal'; export { ImportDatasetModal } from './ImportDatasetModal'; +export { SaveDatasetModal } from './SaveDatasetModal'; +export { SaveQueryModal } from './SaveQueryModal'; diff --git a/superset-frontend/playwright/helpers/api/assertions.ts b/superset-frontend/playwright/helpers/api/assertions.ts index f07927bb9e7..a6fdb1937fe 100644 --- a/superset-frontend/playwright/helpers/api/assertions.ts +++ b/superset-frontend/playwright/helpers/api/assertions.ts @@ -61,6 +61,22 @@ export function expectStatusOneOf( return response; } +/** + * Extract the resource ID from a JSON response body. + * Handles both `{ result: { id } }` and `{ id }` shapes. + * @param response - Playwright Response or APIResponse object + * @returns The extracted numeric ID + */ +export async function extractIdFromResponse( + response: ResponseLike, +): Promise { + const body = await response.json(); + const id: number = body.result?.id ?? body.id; + expect(typeof id, 'Response body must contain a numeric id').toBe('number'); + expect(Number.isNaN(id), 'Response id must not be NaN').toBe(false); + return id; +} + interface ExportZipOptions { /** Directory name containing resource yaml files (e.g. 'charts', 'datasets') */ resourceDir: string; diff --git a/superset-frontend/playwright/helpers/api/savedQuery.ts b/superset-frontend/playwright/helpers/api/savedQuery.ts new file mode 100644 index 00000000000..9c5a5a29d8b --- /dev/null +++ b/superset-frontend/playwright/helpers/api/savedQuery.ts @@ -0,0 +1,47 @@ +/** + * 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 { apiGet, apiDelete, ApiRequestOptions } from './requests'; + +const ENDPOINTS = { + SAVED_QUERY: 'api/v1/saved_query/', +} as const; + +/** + * GET request to fetch a saved query's details + */ +export async function apiGetSavedQuery( + page: Page, + savedQueryId: number, + options?: ApiRequestOptions, +): Promise { + return apiGet(page, `${ENDPOINTS.SAVED_QUERY}${savedQueryId}`, options); +} + +/** + * DELETE request to remove a saved query + */ +export async function apiDeleteSavedQuery( + page: Page, + savedQueryId: number, + options?: ApiRequestOptions, +): Promise { + return apiDelete(page, `${ENDPOINTS.SAVED_QUERY}${savedQueryId}`, options); +} diff --git a/superset-frontend/playwright/helpers/fixtures/testAssets.ts b/superset-frontend/playwright/helpers/fixtures/testAssets.ts index 7a4f0f10c07..b630bbe60ad 100644 --- a/superset-frontend/playwright/helpers/fixtures/testAssets.ts +++ b/superset-frontend/playwright/helpers/fixtures/testAssets.ts @@ -23,6 +23,7 @@ import { apiDeleteDashboard } from '../api/dashboard'; import { apiDeleteDataset } from '../api/dataset'; import { apiDeleteTheme } from '../api/theme'; import { apiDeleteDatabase } from '../api/database'; +import { apiDeleteSavedQuery } from '../api/savedQuery'; /** * Test asset tracker for automatic cleanup after each test. @@ -34,6 +35,7 @@ export interface TestAssets { trackDataset(id: number): void; trackTheme(id: number): void; trackDatabase(id: number): void; + trackSavedQuery(id: number): void; } const EXPECTED_CLEANUP_STATUSES = new Set([200, 202, 204, 404]); @@ -46,6 +48,7 @@ export const test = base.extend<{ testAssets: TestAssets }>({ const datasetIds = new Set(); const themeIds = new Set(); const databaseIds = new Set(); + const savedQueryIds = new Set(); await use({ trackDashboard: id => dashboardIds.add(id), @@ -53,9 +56,29 @@ export const test = base.extend<{ testAssets: TestAssets }>({ trackDataset: id => datasetIds.add(id), trackTheme: id => themeIds.add(id), trackDatabase: id => databaseIds.add(id), + trackSavedQuery: id => savedQueryIds.add(id), }); - // Cleanup order: dashboards → charts → datasets → themes → databases (respects FK dependencies) + // Cleanup order: saved queries → dashboards → charts → datasets → themes → databases (respects FK dependencies) + // Saved queries have no FK dependents, so they can be cleaned up first + await Promise.all( + [...savedQueryIds].map(id => + apiDeleteSavedQuery(page, id, { failOnStatusCode: false }) + .then(response => { + if (!EXPECTED_CLEANUP_STATUSES.has(response.status())) { + console.warn( + `[testAssets] Unexpected status ${response.status()} cleaning up saved query ${id}`, + ); + } + }) + .catch(error => { + console.warn( + `[testAssets] Failed to cleanup saved query ${id}:`, + error, + ); + }), + ), + ); // Use failOnStatusCode: false to avoid throwing on 404 (resource already deleted by test) // Warn on unexpected status codes (401/403/500) that may indicate leaked state await Promise.all( diff --git a/superset-frontend/playwright/pages/AuthPage.ts b/superset-frontend/playwright/pages/AuthPage.ts index 2cb5157e144..397e2a82df1 100644 --- a/superset-frontend/playwright/pages/AuthPage.ts +++ b/superset-frontend/playwright/pages/AuthPage.ts @@ -50,7 +50,9 @@ export class AuthPage { * Navigate to the login page */ async goto(): Promise { - await this.page.goto(URL.LOGIN); + // Use domcontentloaded — the login form is server-rendered and ready before + // all assets load. The default 'load' event may never fire with HMR WebSocket. + await this.page.goto(URL.LOGIN, { waitUntil: 'domcontentloaded' }); } /** diff --git a/superset-frontend/playwright/pages/SqlLabPage.ts b/superset-frontend/playwright/pages/SqlLabPage.ts new file mode 100644 index 00000000000..9c582fc73a7 --- /dev/null +++ b/superset-frontend/playwright/pages/SqlLabPage.ts @@ -0,0 +1,371 @@ +/** + * 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, Response } from '@playwright/test'; +import { AceEditor } from '../components/core/AceEditor'; +import { AgGrid } from '../components/core/AgGrid'; +import { Button } from '../components/core/Button'; +import { EditableTabs } from '../components/core/EditableTabs'; +import { Popover } from '../components/core/Popover'; +import { Select } from '../components/core/Select'; +import { waitForPost } from '../helpers/api/intercepts'; +import { URL } from '../utils/urls'; +import { TIMEOUT } from '../utils/constants'; + +/** + * Page object for SQL Lab. + * + * Selectors verified against source code — see plan for line references. + */ +export class SqlLabPage { + private readonly page: Page; + private readonly editorTabs: EditableTabs; + + private static readonly SELECTORS = { + SQL_EDITOR_TABS: '[data-test="sql-editor-tabs"]', + ADD_TAB_ICON: '[data-test="add-tab-icon"]', + RUN_QUERY_BUTTON: '[data-test="run-query-action"]', + SOUTH_PANE: '[data-test="south-pane"]', + EXPLORE_RESULTS_BUTTON: '[data-test="explore-results-button"]', + SAVE_BUTTON: 'button[aria-label="Save"]', + ACE_EDITOR: '.ace_editor', + LEFT_BAR: '[data-test="sql-editor-left-bar"]', + DATABASE_SELECTOR: '[data-test="DatabaseSelector"]', + LIMIT_DROPDOWN: '.limitDropdown', + SAVE_DATASET_BUTTON: 'button[aria-label="Save dataset"]', + } as const; + + constructor(page: Page) { + this.page = page; + this.editorTabs = new EditableTabs( + page, + page.locator(SqlLabPage.SELECTORS.SQL_EDITOR_TABS), + ); + } + + // ── Navigation ── + + async goto(): Promise { + await this.page.goto(URL.SQLLAB, { waitUntil: 'domcontentloaded' }); + } + + async waitForPageLoad(options?: { timeout?: number }): Promise { + // SQL Lab with dev server can be slow on first load (webpack HMR + React hydration) + const timeout = options?.timeout ?? TIMEOUT.QUERY_EXECUTION; + await this.editorTabs.element.waitFor({ state: 'visible', timeout }); + } + + /** + * Navigate to SQL Lab and wait until the editor is ready. + * Convenience method combining goto + waitForPageLoad + ensureEditorReady. + */ + async gotoAndReady(): Promise { + await this.goto(); + await this.waitForPageLoad(); + await this.ensureEditorReady(); + } + + /** + * Ensures at least one query editor tab exists. Creates one if SQL Lab + * is in the empty state ("Add a new tab to create SQL Query"). + * Waits for the ace editor to be ready before returning. + * + * Uses a two-stage check to handle three states correctly: + * 1. Empty state (CI): type="card" with 0 queryEditors, no editor → create tab + * 2. Loading after reload: real tabs exist, editor hasn't mounted yet → just wait + * 3. Normal: tabs + editor present → ready immediately + * + * Stage 1 checks editor presence (catches empty + loading). + * Stage 2 checks the Ant Design tabs type to distinguish real tabs + * (type="editable-card") from the empty state (type="card"). The React + * source (TabbedSqlEditors) sets type based on queryEditors.length, + * so this directly reflects whether persisted tabs exist. + */ + async ensureEditorReady(): Promise { + // Page-global check: are there ANY editors in the DOM (any tab)? + const anyEditor = this.page.locator(SqlLabPage.SELECTORS.ACE_EDITOR); + let tabSyncPromise: Promise | null = null; + + if ((await anyEditor.count()) === 0) { + // No editor visible. Check if real query editors exist (editable-card) + // or if this is the empty state (card type, 0 queryEditors). + // type="editable-card" → queryEditors.length > 0 (even 1 real tab). + // type="card" → queryEditors.length === 0 (true empty state). + const isEditableCard = await this.editorTabs.element.evaluate(el => + el.classList.contains('ant-tabs-editable-card'), + ); + if (!isEditableCard) { + // Register before clicking — EditorAutoSync POSTs the new tab + // within its 5 s interval, so capture it before any await. + tabSyncPromise = waitForPost(this.page, /tabstateview\/?$/, { + timeout: 10_000, + }); + // True empty state — click add-tab icon (works in card mode) + await this.editorTabs.element + .locator(SqlLabPage.SELECTORS.ADD_TAB_ICON) + .first() + .click(); + } + // If editable-card: real tabs exist, editor is still mounting — just wait + } + + // Wait for the editor in the ACTIVE panel, not page-global .first(). + // In persisted multi-tab sessions, .first() can resolve to a hidden + // inactive editor. activePanel scopes to the visible tab panel. + await this.activePanel + .locator(SqlLabPage.SELECTORS.ACE_EDITOR) + .waitFor({ state: 'visible' }); + await this.editor.waitForReady(); + + // If we created the initial tab, wait for its backend sync to complete. + // This prevents later waitForPost calls from accidentally matching + // this tab's creation POST instead of a subsequent tab's. + if (tabSyncPromise) { + await tabSyncPromise; + } + } + + // ── Active Tab Panel ── + + /** + * Gets the active tab panel. Ant Design keeps inactive tab panels mounted + * but sets aria-hidden="true" on them. Using :not([aria-hidden="true"]) + * is more reliable than :visible during tab-switch animations where both + * panels may briefly have non-zero dimensions. + */ + private get activePanel(): Locator { + return this.page + .locator('[role="tabpanel"]:not([aria-hidden="true"])') + .filter({ has: this.page.locator(SqlLabPage.SELECTORS.ACE_EDITOR) }); + } + + // ── Elements ── + + get editor(): AceEditor { + return new AceEditor( + this.page, + this.activePanel.locator(SqlLabPage.SELECTORS.ACE_EDITOR), + ); + } + + get resultsGrid(): AgGrid { + return new AgGrid( + this.page, + this.activePanel + .locator(SqlLabPage.SELECTORS.SOUTH_PANE) + .locator('[role="grid"]'), + ); + } + + get resultsPane(): Locator { + return this.activePanel.locator(SqlLabPage.SELECTORS.SOUTH_PANE); + } + + get errorAlert(): Locator { + return this.resultsPane.locator('.ant-alert-error'); + } + + get databaseSelector(): Button { + return new Button( + this.page, + this.page.locator( + `${SqlLabPage.SELECTORS.LEFT_BAR} ${SqlLabPage.SELECTORS.DATABASE_SELECTOR}`, + ), + ); + } + + get runQueryButton(): Button { + return new Button( + this.page, + this.activePanel.locator(SqlLabPage.SELECTORS.RUN_QUERY_BUTTON), + ); + } + + get saveButton(): Button { + return new Button( + this.page, + this.activePanel.locator(SqlLabPage.SELECTORS.SAVE_BUTTON), + ); + } + + get saveDatasetButton(): Button { + return new Button( + this.page, + this.activePanel.locator(SqlLabPage.SELECTORS.SAVE_DATASET_BUTTON), + ); + } + + get createChartButton(): Button { + return new Button( + this.page, + this.activePanel.locator(SqlLabPage.SELECTORS.EXPLORE_RESULTS_BUTTON), + ); + } + + // ── Editor Convenience ── + + async setQuery(sql: string): Promise { + await this.editor.setText(sql); + } + + async getQuery(): Promise { + return this.editor.getText(); + } + + // ── Tab Management ── + + async getTabCount(): Promise { + return this.editorTabs.getTabCount(); + } + + async getTabNames(): Promise { + return this.editorTabs.getTabNames(); + } + + async getActiveTabName(): Promise { + return this.editorTabs.getActiveTabName(); + } + + async addTab(): Promise { + await this.editorTabs.addTab(); + } + + async addTabByShortcut(): Promise { + const modifier = process.platform === 'win32' ? 'Control+q' : 'Control+t'; + await this.page.keyboard.press(modifier); + } + + async closeLastTab(): Promise { + const countBefore = await this.getTabCount(); + await this.editorTabs.removeLastTab(); + // Wait for tab count to decrease + await this.page.waitForFunction( + ([selector, expected]) => { + const container = document.querySelector(selector); + if (!container) return false; + const nav = container.querySelector(':scope > .ant-tabs-nav'); + if (!nav) return false; + return nav.querySelectorAll('.ant-tabs-tab').length === expected; + }, + [SqlLabPage.SELECTORS.SQL_EDITOR_TABS, countBefore - 1] as const, + { timeout: TIMEOUT.UI_TRANSITION }, + ); + } + + getTab(name: string): Locator { + return this.editorTabs.getTab(name); + } + + // ── Database Selection (Left Sidebar) ── + + async selectDatabase(dbName: string): Promise { + await this.databaseSelector.click(); + + const popover = new Popover(this.page); + await popover.waitForVisible(); + + // Target the .ant-select wrapper (not the combobox input) because the + // selection-item overlay intercepts pointer events on the input. + const dbSelect = popover.element + .locator(SqlLabPage.SELECTORS.DATABASE_SELECTOR) + .locator('.ant-select') + .first(); + const select = new Select(this.page, dbSelect); + await select.selectOption(dbName); + + await popover.getButton('Select').click(); + await popover + .waitForHidden({ timeout: TIMEOUT.UI_TRANSITION }) + .catch(error => { + if (!(error instanceof Error) || error.name !== 'TimeoutError') { + throw error; + } + }); + } + + // ── Query Execution ── + + /** + * Sets SQL, runs the query, and waits for the API response. + * Also observes the QueryStatusBar (.ant-steps) loading indicator to + * confirm the UI entered the execution cycle — this unmounts the old + * results grid, so waitForQueryResults() can trust that any grid it + * finds afterward contains data from THIS execution. + */ + async executeQuery(sql: string): Promise { + await this.setQuery(sql); + const responsePromise = waitForPost(this.page, 'api/v1/sqllab/execute/', { + timeout: TIMEOUT.QUERY_EXECUTION, + }); + // Start observing the loading indicator BEFORE clicking Run so we + // catch it even for fast queries. QueryStatusBar (.ant-steps) appears + // when SQL Lab enters the running state and unmounts the results grid. + const loadingStarted = this.resultsPane + .locator('.ant-steps') + .waitFor({ state: 'visible', timeout: TIMEOUT.QUERY_EXECUTION }); + await this.runQueryButton.click(); + const [, response] = await Promise.all([loadingStarted, responsePromise]); + return response; + } + + /** + * Wait for fresh query results to render in the AG Grid. + * Waits for the QueryStatusBar to disappear first, proving the execution + * cycle completed and React rendered the post-query grid. + * @param expectHeader - A column header that must be visible before returning. + * @param options.timeout - How long to wait (default: TIMEOUT.QUERY_EXECUTION) + */ + async waitForQueryResults( + expectHeader: string, + options?: { timeout?: number }, + ): Promise { + const timeout = options?.timeout ?? TIMEOUT.QUERY_EXECUTION; + // Wait for QueryStatusBar to disappear — proves the loading → ready + // transition completed. If already hidden (fast query finished before + // this call), resolves immediately since executeQuery() already observed + // the loading state appear. + await this.resultsPane + .locator('.ant-steps') + .waitFor({ state: 'hidden', timeout }); + const grid = this.resultsGrid.element; + await grid.waitFor({ state: 'visible', timeout }); + await grid + .locator('.ag-header-cell', { hasText: expectHeader }) + .first() + .waitFor({ state: 'visible', timeout }); + } + + // ── Row Limit ── + + async getRowLimit(): Promise { + const text = await this.activePanel + .locator(SqlLabPage.SELECTORS.LIMIT_DROPDOWN) + .textContent(); + return text?.trim() ?? ''; + } + + /** + * Set the row limit via the Limit dropdown in the active panel. + * @param limit - The menu item label to select (e.g., "10", "100") + */ + async setRowLimit(limit: string): Promise { + await this.activePanel.locator(SqlLabPage.SELECTORS.LIMIT_DROPDOWN).click(); + await this.page.getByRole('menuitem', { name: limit, exact: true }).click(); + } +} diff --git a/superset-frontend/playwright/tests/auth/login.spec.ts b/superset-frontend/playwright/tests/auth/login.spec.ts index 38fd48ee957..003e36f94a1 100644 --- a/superset-frontend/playwright/tests/auth/login.spec.ts +++ b/superset-frontend/playwright/tests/auth/login.spec.ts @@ -44,25 +44,18 @@ test.beforeEach(async ({ page }) => { test('should redirect to login with incorrect username and password', async ({ page, }) => { - // Setup request interception before login attempt - const loginRequestPromise = authPage.waitForLoginRequest(); + // The form submission is async (SupersetClient.postForm uses ensureAuth) + // so listen for the page reload before triggering the login + await Promise.all([ + page.waitForEvent('load', { timeout: TIMEOUT.PAGE_LOAD }), + authPage.loginWithCredentials('wronguser', 'wrongpassword'), + ]); - // 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, - }); + // After the reload, wait for the React login form to render + await authPage.waitForLoginForm(); // Verify we stay on login page - const currentUrl = await authPage.getCurrentUrl(); - expect(currentUrl).toContain(URL.LOGIN); + expect(page.url()).toContain(URL.LOGIN); // Verify error message is shown const hasError = await authPage.hasLoginError(); @@ -70,16 +63,12 @@ test('should redirect to login with incorrect username and password', async ({ }); 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); + // Use waitForLoginSuccess (proven in global-setup) — guards against race + // conditions with cookie check before falling back to response interception + await authPage.waitForLoginSuccess({ timeout: TIMEOUT.PAGE_LOAD }); // Wait for successful redirect to welcome page await page.waitForURL(url => url.pathname.endsWith(URL.WELCOME), { diff --git a/superset-frontend/playwright/tests/sqllab/sqllab.spec.ts b/superset-frontend/playwright/tests/sqllab/sqllab.spec.ts new file mode 100644 index 00000000000..7c6bf4896ee --- /dev/null +++ b/superset-frontend/playwright/tests/sqllab/sqllab.spec.ts @@ -0,0 +1,315 @@ +/** + * 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. + */ + +/** + * SQL Lab E2E tests — sequential via chromium-sqllab project. + * + * Tab state is stored server-side per user (/tabstateview/*), and all workers + * share the same authenticated user. Parallel workers adding/removing tabs + * would cause nondeterministic tab counts and cross-worker deletions. + * See playwright.config.ts chromium-sqllab project for details. + */ + +import { test, expect } from '../../helpers/fixtures/testAssets'; +import { SqlLabPage } from '../../pages/SqlLabPage'; +import { ExplorePage } from '../../pages/ExplorePage'; +import { Input } from '../../components/core'; +import { SaveQueryModal } from '../../components/modals/SaveQueryModal'; +import { SaveDatasetModal } from '../../components/modals/SaveDatasetModal'; +import { waitForPost } from '../../helpers/api/intercepts'; +import { + expectStatus, + extractIdFromResponse, +} from '../../helpers/api/assertions'; +import { apiGetSavedQuery } from '../../helpers/api/savedQuery'; +import { TIMEOUT } from '../../utils/constants'; +import { URL } from '../../utils/urls'; + +let sqlLabPage: SqlLabPage; + +test.beforeEach(async ({ page }) => { + test.setTimeout(TIMEOUT.SLOW_TEST); + sqlLabPage = new SqlLabPage(page); + await sqlLabPage.gotoAndReady(); +}); + +// ── Query Execution ── + +test('executes a simple SELECT query and displays results', async () => { + await expect(sqlLabPage.databaseSelector.element).toBeVisible(); + + const response = await sqlLabPage.executeQuery('SELECT 1 AS test_col'); + expectStatus(response, 200); + + await sqlLabPage.waitForQueryResults('test_col'); + const headers = await sqlLabPage.resultsGrid.getHeaderTexts(); + expect(headers.some(h => h.includes('test_col'))).toBe(true); +}); + +test('shows error message for invalid SQL', async () => { + const invalidTable = 'a_table_that_does_not_exist_xyz_pw'; + await sqlLabPage.executeQuery(`SELECT * FROM ${invalidTable}`); + + const { errorAlert } = sqlLabPage; + await expect(errorAlert).toBeVisible({ timeout: TIMEOUT.QUERY_EXECUTION }); + // Assert the error references the specific table name, proving this query + // triggered the failure — not a stale error from a prior test or session. + await expect(errorAlert).toContainText(invalidTable); +}); + +test('re-runs a query and refreshes results', async () => { + const firstResponse = await sqlLabPage.executeQuery('SELECT 1 AS first_col'); + expectStatus(firstResponse, 200); + await sqlLabPage.waitForQueryResults('first_col'); + + const firstHeaders = await sqlLabPage.resultsGrid.getHeaderTexts(); + expect(firstHeaders.some(h => h.includes('first_col'))).toBe(true); + + const secondResponse = await sqlLabPage.executeQuery( + 'SELECT 2 AS second_col', + ); + expectStatus(secondResponse, 200); + await sqlLabPage.waitForQueryResults('second_col'); + + const secondHeaders = await sqlLabPage.resultsGrid.getHeaderTexts(); + expect(secondHeaders.some(h => h.includes('second_col'))).toBe(true); + expect(secondHeaders.some(h => h.includes('first_col'))).toBe(false); +}); + +// ── Tabs ── + +test('creates a new tab via button', async () => { + const initialTabCount = await sqlLabPage.getTabCount(); + + await sqlLabPage.addTab(); + await sqlLabPage.editor.waitForReady(); + + expect(await sqlLabPage.getTabCount()).toBe(initialTabCount + 1); + + const defaultContent = await sqlLabPage.getQuery(); + expect(defaultContent).toContain('SELECT'); + + await sqlLabPage.closeLastTab(); + expect(await sqlLabPage.getTabCount()).toBe(initialTabCount); +}); + +test('closes a tab via close button', async () => { + const initialTabCount = await sqlLabPage.getTabCount(); + + await sqlLabPage.addTab(); + await sqlLabPage.editor.waitForReady(); + expect(await sqlLabPage.getTabCount()).toBe(initialTabCount + 1); + + await sqlLabPage.closeLastTab(); + expect(await sqlLabPage.getTabCount()).toBe(initialTabCount); +}); + +test('preserves query state when switching tabs', async () => { + const tabOneSql = `SELECT 'tab_one_${Date.now()}'`; + const tabTwoSql = `SELECT 'tab_two_${Date.now()}'`; + + const firstTabName = await sqlLabPage.getActiveTabName(); + + await sqlLabPage.setQuery(tabOneSql); + + await sqlLabPage.addTab(); + await sqlLabPage.editor.waitForReady(); + const secondTabName = await sqlLabPage.getActiveTabName(); + await sqlLabPage.setQuery(tabTwoSql); + + // Use positional selection — tab names can collide in CI (both tabs may be + // named "Untitled Query 1" depending on server-side tab state from prior tests). + // Content assertions use toPass() because the editor hydrates asynchronously + // after the tab switch — the Ace editor mounts first, then Redux populates it. + await sqlLabPage.getTab(firstTabName).first().click(); + await sqlLabPage.editor.waitForReady(); + await expect(async () => { + expect(await sqlLabPage.getQuery()).toContain('tab_one_'); + }).toPass({ timeout: TIMEOUT.UI_TRANSITION }); + + await sqlLabPage.getTab(secondTabName).last().click(); + await sqlLabPage.editor.waitForReady(); + await expect(async () => { + expect(await sqlLabPage.getQuery()).toContain('tab_two_'); + }).toPass({ timeout: TIMEOUT.UI_TRANSITION }); + + await sqlLabPage.closeLastTab(); +}); + +test('should open new tab by keyboard shortcut with correct defaults', async ({ + page, +}) => { + const initialTabCount = await sqlLabPage.getTabCount(); + + await sqlLabPage.setQuery('some random query string'); + + // Register before addTabByShortcut — EditorAutoSync POSTs the new tab + // within its 5 s interval, so the POST can fire before any later line. + const tabStatePromise = waitForPost(page, /tabstateview\/?$/); + + await sqlLabPage.addTabByShortcut(); + await sqlLabPage.editor.waitForReady(); + expect(await sqlLabPage.getTabCount()).toBe(initialTabCount + 1); + + // Verify new tab has default SQL (not carried over from previous tab) + const defaultContent = await sqlLabPage.getQuery(); + expect(defaultContent).toContain('SELECT'); + + // Wait for the auto-sync POST that persists the new tab to the backend + await tabStatePromise; + + await page.reload(); + await sqlLabPage.waitForPageLoad(); + await sqlLabPage.ensureEditorReady(); + + expect(await sqlLabPage.getTabCount()).toBe(initialTabCount + 1); + + await sqlLabPage.closeLastTab(); + expect(await sqlLabPage.getTabCount()).toBe(initialTabCount); +}); + +// ── Save and Share ── + +test('saves a query and loads it from saved queries', async ({ + page, + testAssets, +}) => { + const queryText = 'SELECT 1 AS saved_test_col'; + const savedQueryTitle = `pw_test_saved_query_${Date.now()}`; + + await expect(sqlLabPage.databaseSelector.element).toBeVisible(); + + const executeResponse = await sqlLabPage.executeQuery(queryText); + expectStatus(executeResponse, 200); + await sqlLabPage.waitForQueryResults('saved_test_col'); + + await sqlLabPage.saveButton.click(); + const saveModal = new SaveQueryModal(page); + await saveModal.waitForReady(); + + await saveModal.fillName(savedQueryTitle); + + const savePromise = waitForPost(page, 'api/v1/saved_query/', { + timeout: TIMEOUT.API_RESPONSE, + }); + await saveModal.clickFooterButton('Save'); + const saveResponse = await savePromise; + expectStatus(saveResponse, 201); + + const savedQueryId = await extractIdFromResponse(saveResponse); + testAssets.trackSavedQuery(savedQueryId); + + await saveModal.waitForHidden(); + + const getResponse = await apiGetSavedQuery(page, savedQueryId); + const savedQuery = (await getResponse.json()).result; + expect(savedQuery.sql).toContain('saved_test_col'); + expect(savedQuery.label).toBe(savedQueryTitle); + + // Navigate through the Saved Queries list UI (not deep-link) to verify + // the query appears in the list and can be loaded from there. + await page.goto(URL.SAVED_QUERIES_LIST, { waitUntil: 'domcontentloaded' }); + await page.locator('.ant-table').waitFor({ + state: 'visible', + timeout: TIMEOUT.PAGE_LOAD, + }); + + // Search for the saved query by its unique name + const searchInput = new Input(page, 'input[data-test="filters-search"]'); + await searchInput.fill(savedQueryTitle); + + // Wait for the filtered row to appear in the table + const queryLink = page.getByRole('link', { name: savedQueryTitle }); + await queryLink.waitFor({ state: 'visible', timeout: TIMEOUT.API_RESPONSE }); + + // Navigate to SQL Lab with the saved query ID. The list-view React Router + // uses makeUrl() which double-prefixes with basename in CI, causing + // the click to silently fail. Direct navigation tests the loading path + // reliably — the list search above already proves the query appears in the UI. + await page.goto(`${URL.SQLLAB}?savedQueryId=${savedQueryId}`); + await sqlLabPage.waitForPageLoad(); + await sqlLabPage.ensureEditorReady(); + + const loadedSql = await sqlLabPage.getQuery(); + expect(loadedSql).toContain('saved_test_col'); +}); + +test('creates a dataset from query results', async ({ page, testAssets }) => { + await sqlLabPage.selectDatabase('examples'); + + const executeResponse = await sqlLabPage.executeQuery( + 'SELECT 1 AS ds_test_col', + ); + expectStatus(executeResponse, 200); + await sqlLabPage.waitForQueryResults('ds_test_col'); + + await sqlLabPage.saveDatasetButton.click(); + + const saveDatasetModal = new SaveDatasetModal(page); + await saveDatasetModal.waitForReady(); + + const datasetName = `pw_test_dataset_${Date.now()}`; + await saveDatasetModal.fillName(datasetName); + + const datasetCreatePromise = waitForPost(page, 'api/v1/dataset/', { + timeout: TIMEOUT.API_RESPONSE, + }); + const newPagePromise = page.context().waitForEvent('page', { + timeout: TIMEOUT.API_RESPONSE, + }); + + await saveDatasetModal.clickFooterButton('Save & Explore'); + + const createResponse = await datasetCreatePromise; + const datasetId = await extractIdFromResponse(createResponse); + testAssets.trackDataset(datasetId); + + const newPage = await newPagePromise; + + const explorePage = new ExplorePage(newPage); + await explorePage.waitForPageLoad({ timeout: TIMEOUT.PAGE_LOAD }); + + const loadedDatasetName = await explorePage.getDatasetName(); + expect(loadedDatasetName).toContain(datasetName); + + await newPage.close(); +}); + +// ── Create Chart ── + +test('should navigate to Explore from SQL Lab query results', async ({ + page, +}) => { + await sqlLabPage.selectDatabase('examples'); + + const query = 'SELECT gender, name FROM birth_names'; + const executeResponse = await sqlLabPage.executeQuery(query); + expectStatus(executeResponse, 200); + + await sqlLabPage.waitForQueryResults('gender'); + + await expect(sqlLabPage.createChartButton.element).toBeEnabled(); + await sqlLabPage.createChartButton.click(); + + const explorePage = new ExplorePage(page); + await explorePage.waitForPageLoad({ timeout: TIMEOUT.PAGE_LOAD }); + + const datasetName = await explorePage.getDatasetName(); + expect(datasetName).toContain(query); +}); diff --git a/superset-frontend/playwright/utils/constants.ts b/superset-frontend/playwright/utils/constants.ts index 7f882d9630d..ab142b01208 100644 --- a/superset-frontend/playwright/utils/constants.ts +++ b/superset-frontend/playwright/utils/constants.ts @@ -58,4 +58,20 @@ export const TIMEOUT = { * File import/upload operations (upload + server processing) */ FILE_IMPORT: 30000, // 30s for file import operations + + /** + * UI transition timeout (tab close, popover dismiss, dropdown close) + */ + UI_TRANSITION: 5000, // 5s ceiling for Ant Design animations (~300-500ms actual) + + /** + * SQL query execution (query → backend processing → results) + */ + QUERY_EXECUTION: 15000, // 15s for SQL queries that may take longer than default expect timeout + + /** + * Extended test timeout for multi-step tests (page load + query execution + assertions). + * Use with test.setTimeout() when the default 30s test timeout is insufficient. + */ + SLOW_TEST: 60000, // 60s for tests that chain multiple slow operations } as const; diff --git a/superset-frontend/playwright/utils/urls.ts b/superset-frontend/playwright/utils/urls.ts index fa5be71c8f1..ed78c0b1c53 100644 --- a/superset-frontend/playwright/utils/urls.ts +++ b/superset-frontend/playwright/utils/urls.ts @@ -33,6 +33,7 @@ export const URL = { DASHBOARD_LIST: 'dashboard/list/', DATASET_LIST: 'tablemodelview/list', LOGIN: 'login/', + SAVED_QUERIES_LIST: 'savedqueryview/list/', SQLLAB: 'sqllab', WELCOME: 'superset/welcome/', } as const;