test(sqllab): migrate Cypress E2E tests to Playwright (#39071)

This commit is contained in:
Joe Li
2026-04-14 10:31:37 -07:00
committed by GitHub
parent 002d8ad1e4
commit 3e25f02da9
25 changed files with 1156 additions and 454 deletions

View File

@@ -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);
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});
};

View File

@@ -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');
});
});
});

View File

@@ -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

View 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() ?? '';
}
}

View 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();
}
}

View File

@@ -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> {

View 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 }),
);
}
}

View File

@@ -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') {

View File

@@ -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

View File

@@ -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';

View File

@@ -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(),
);
}
/**

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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';

View File

@@ -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;

View 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);
}

View File

@@ -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(

View File

@@ -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' });
}
/**

View 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();
}
}

View File

@@ -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), {

View 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);
});

View File

@@ -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;

View File

@@ -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;