mirror of
https://github.com/apache/superset.git
synced 2026-06-03 14:49:23 +00:00
test(sqllab): migrate Cypress E2E tests to Playwright (#39071)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
85
superset-frontend/playwright/components/core/AgGrid.ts
Normal file
85
superset-frontend/playwright/components/core/AgGrid.ts
Normal file
@@ -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<void> {
|
||||
await this.locator
|
||||
.locator(AG_GRID_SELECTORS.BODY_ROW)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get header cell texts
|
||||
*/
|
||||
async getHeaderTexts(): Promise<string[]> {
|
||||
return this.locator
|
||||
.locator(AG_GRID_SELECTORS.HEADER_CELL)
|
||||
.allTextContents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of visible data rows
|
||||
*/
|
||||
async getRowCount(): Promise<number> {
|
||||
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<string> {
|
||||
const text = await this.locator
|
||||
.locator(AG_GRID_SELECTORS.BODY_ROW)
|
||||
.nth(row)
|
||||
.locator(AG_GRID_SELECTORS.CELL)
|
||||
.nth(col)
|
||||
.textContent();
|
||||
return text?.trim() ?? '';
|
||||
}
|
||||
}
|
||||
46
superset-frontend/playwright/components/core/EditableTabs.ts
Normal file
46
superset-frontend/playwright/components/core/EditableTabs.ts
Normal file
@@ -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<void> {
|
||||
await this.element.getByRole('button', { name: 'Add tab' }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the remove button on the last tab.
|
||||
*/
|
||||
async removeLastTab(): Promise<void> {
|
||||
await this.nav.locator('.ant-tabs-tab-remove').last().click();
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
|
||||
53
superset-frontend/playwright/components/core/Popover.ts
Normal file
53
superset-frontend/playwright/components/core/Popover.ts
Normal file
@@ -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<void> {
|
||||
await this.locator.waitFor({ state: 'visible', ...options });
|
||||
}
|
||||
|
||||
async waitForHidden(options?: { timeout?: number }): Promise<void> {
|
||||
await this.locator.waitFor({ state: 'hidden', ...options });
|
||||
}
|
||||
|
||||
getButton(name: string): Button {
|
||||
return new Button(
|
||||
this.page,
|
||||
this.locator.getByRole('button', { name, exact: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<number> {
|
||||
return this.nav.locator('.ant-tabs-tab').count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text content of all tabs.
|
||||
*/
|
||||
async getTabNames(): Promise<string[]> {
|
||||
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<string> {
|
||||
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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void> {
|
||||
await this.nameInput.clear();
|
||||
await this.nameInput.fill(name);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
await this.nameInput.clear();
|
||||
await this.nameInput.fill(name);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -61,6 +61,22 @@ export function expectStatusOneOf<T extends ResponseLike>(
|
||||
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<number> {
|
||||
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;
|
||||
|
||||
47
superset-frontend/playwright/helpers/api/savedQuery.ts
Normal file
47
superset-frontend/playwright/helpers/api/savedQuery.ts
Normal file
@@ -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<APIResponse> {
|
||||
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<APIResponse> {
|
||||
return apiDelete(page, `${ENDPOINTS.SAVED_QUERY}${savedQueryId}`, options);
|
||||
}
|
||||
@@ -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<number>();
|
||||
const themeIds = new Set<number>();
|
||||
const databaseIds = new Set<number>();
|
||||
const savedQueryIds = new Set<number>();
|
||||
|
||||
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(
|
||||
|
||||
@@ -50,7 +50,9 @@ export class AuthPage {
|
||||
* Navigate to the login page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
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' });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
371
superset-frontend/playwright/pages/SqlLabPage.ts
Normal file
371
superset-frontend/playwright/pages/SqlLabPage.ts
Normal file
@@ -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<void> {
|
||||
await this.page.goto(URL.SQLLAB, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
async waitForPageLoad(options?: { timeout?: number }): Promise<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
// Page-global check: are there ANY editors in the DOM (any tab)?
|
||||
const anyEditor = this.page.locator(SqlLabPage.SELECTORS.ACE_EDITOR);
|
||||
let tabSyncPromise: Promise<Response> | 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<void> {
|
||||
await this.editor.setText(sql);
|
||||
}
|
||||
|
||||
async getQuery(): Promise<string> {
|
||||
return this.editor.getText();
|
||||
}
|
||||
|
||||
// ── Tab Management ──
|
||||
|
||||
async getTabCount(): Promise<number> {
|
||||
return this.editorTabs.getTabCount();
|
||||
}
|
||||
|
||||
async getTabNames(): Promise<string[]> {
|
||||
return this.editorTabs.getTabNames();
|
||||
}
|
||||
|
||||
async getActiveTabName(): Promise<string> {
|
||||
return this.editorTabs.getActiveTabName();
|
||||
}
|
||||
|
||||
async addTab(): Promise<void> {
|
||||
await this.editorTabs.addTab();
|
||||
}
|
||||
|
||||
async addTabByShortcut(): Promise<void> {
|
||||
const modifier = process.platform === 'win32' ? 'Control+q' : 'Control+t';
|
||||
await this.page.keyboard.press(modifier);
|
||||
}
|
||||
|
||||
async closeLastTab(): Promise<void> {
|
||||
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<void> {
|
||||
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<Response> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
await this.activePanel.locator(SqlLabPage.SELECTORS.LIMIT_DROPDOWN).click();
|
||||
await this.page.getByRole('menuitem', { name: limit, exact: true }).click();
|
||||
}
|
||||
}
|
||||
@@ -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), {
|
||||
|
||||
315
superset-frontend/playwright/tests/sqllab/sqllab.spec.ts
Normal file
315
superset-frontend/playwright/tests/sqllab/sqllab.spec.ts
Normal file
@@ -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
|
||||
// <Link> 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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user