You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/04/02 18:04:39 UTC
(superset) 01/02: test(sqllab): migrate Cypress E2E tests to Playwright
This is an automated email from the ASF dual-hosted git repository.
jli pushed a commit to branch feat-sql-lab-to-playwright
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 6802942e93465daa8ba85a2ea44ec4406c32a171
Author: Joe Li <jo...@preset.io>
AuthorDate: Tue Mar 31 21:19:05 2026 -0700
test(sqllab): migrate Cypress E2E tests to Playwright
Migrate all SQL Lab Cypress tests to Playwright with true end-to-end
assertions. Each test is justified as E2E: tab tests validate persistence
across page reload via the tabstateview API, query tests exercise the
full browser-to-database pipeline, and the create-chart test validates
cross-feature SQL Lab to Explore navigation.
New files:
- SqlLabPage page object with selectors verified against source
- AgGrid component wrapper for AG Grid result tables
- savedQuery API helpers and testAssets cleanup support
- 5 Playwright tests: tab persistence (2), query execution (2),
create chart from query (1)
Deleted: all 4 Cypress SQL Lab test files (tabs.test.ts, query.test.ts,
_skip.sourcePanel.index.test.js, sqllab.helper.js). The source panel
test was dropped (covered by SqlEditorLeftBar RTL tests).
Also fixes AuthPage.goto() to use waitUntil: domcontentloaded for
compatibility with webpack dev server HMR.
Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
.../e2e/sqllab/_skip.sourcePanel.index.test.js | 73 ------
.../cypress-base/cypress/e2e/sqllab/query.test.ts | 192 --------------
.../cypress/e2e/sqllab/sqllab.helper.js | 41 ---
.../cypress-base/cypress/e2e/sqllab/tabs.test.ts | 113 ---------
.../playwright/components/core/AgGrid.ts | 85 +++++++
.../playwright/components/core/index.ts | 1 +
.../core/index.ts => helpers/api/savedQuery.ts} | 41 ++-
.../playwright/helpers/fixtures/testAssets.ts | 25 +-
superset-frontend/playwright/pages/AuthPage.ts | 4 +-
superset-frontend/playwright/pages/SqlLabPage.ts | 275 +++++++++++++++++++++
.../tests/sqllab/create-chart-from-query.spec.ts | 68 +++++
.../tests/sqllab/query-execution.spec.ts | 110 +++++++++
.../playwright/tests/sqllab/tabs.spec.ts | 103 ++++++++
superset-frontend/playwright/utils/constants.ts | 5 +
14 files changed, 702 insertions(+), 434 deletions(-)
diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js b/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js
deleted file mode 100644
index 4b24c844364..00000000000
--- a/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { selectResultsTab } from './sqllab.helper';
-
-describe.skip('SqlLab datasource panel', () => {
- beforeEach(() => {
- cy.visit('/sqllab');
- });
-
- // TODO the test below is flaky, and has been disabled for the time being
- // (notice the `it.skip`)
- it('creates a table preview when a database, schema, and table are selected', () => {
- cy.intercept('**/superset/table/**').as('tableMetadata');
-
- // it should have dropdowns to select database, schema, and table
- cy.get('.sql-toolbar .Select').should('have.length', 3);
-
- cy.get('.sql-toolbar .table-schema').should('not.exist');
- cy.get('[data-test="filterable-table-container"]').should('not.exist');
-
- cy.get('.sql-toolbar .Select')
- .eq(0) // database select
- .within(() => {
- // note: we have to set force: true because the input is invisible / cypress throws
- cy.get('input').type('main{enter}', { force: true });
- });
-
- cy.get('.sql-toolbar .Select')
- .eq(1) // schema select
- .within(() => {
- cy.get('input').type('main{enter}', { force: true });
- });
-
- cy.get('.sql-toolbar .Select')
- .eq(2) // table select
- .within(() => {
- cy.get('input').type('birth_names{enter}', { force: true });
- });
-
- cy.wait('@tableMetadata');
-
- cy.get('.sql-toolbar .table-schema').should('have.length', 1);
- selectResultsTab().should('have.length', 1);
-
- // add another table and check for added schema + preview
- cy.get('.sql-toolbar .Select')
- .eq(2)
- .within(() => {
- cy.get('input').type('logs{enter}', { force: true });
- });
-
- cy.wait('@tableMetadata');
-
- cy.get('.sql-toolbar .table-schema').should('have.length', 2);
- selectResultsTab().should('have.length', 2);
- });
-});
diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts b/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts
deleted file mode 100644
index 389698dcb8d..00000000000
--- a/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { nanoid } from 'nanoid';
-import { selectResultsTab, assertSQLLabResultsAreEqual } from './sqllab.helper';
-
-function parseClockStr(node: JQuery) {
- return Number.parseFloat(node.text().replace(/:/g, ''));
-}
-
-describe('SqlLab query panel', () => {
- beforeEach(() => {
- cy.visit('/sqllab');
- });
-
- it.skip('supports entering and running a query', () => {
- // row limit has to be < ~10 for us to be able to determine how many rows
- // are fetched below (because React _Virtualized_ does not render all rows)
- let clockTime = 0;
-
- cy.intercept({
- method: 'POST',
- url: '**/api/v1/sqllab/execute/',
- }).as('mockSQLResponse');
-
- cy.get('.TableSelector .Select:eq(0)').click();
- cy.get('.TableSelector .Select:eq(0) input[type=text]').focus();
- cy.focused().type('{enter}');
-
- cy.get('#brace-editor textarea').focus();
- cy.focused().clear();
- cy.focused().type(`{selectall}{backspace}SELECT 1`);
-
- cy.get('#js-sql-toolbar button:eq(0)').eq(0).click();
-
- // wait for 300 milliseconds
- cy.wait(300);
-
- // started timer
- cy.get('.sql-toolbar .label-success').then(node => {
- clockTime = parseClockStr(node);
- // should be longer than 0.2s
- expect(clockTime).greaterThan(0.2);
- });
-
- cy.wait('@mockSQLResponse');
-
- // timer is increasing
- cy.get('.sql-toolbar .label-success').then(node => {
- const newClockTime = parseClockStr(node);
- expect(newClockTime).greaterThan(0.9);
- clockTime = newClockTime;
- });
-
- // rerun the query
- cy.get('#js-sql-toolbar button:eq(0)').eq(0).click();
-
- // should restart the timer
- cy.get('.sql-toolbar .label-success').contains('00:00:00');
- cy.wait('@mockSQLResponse');
- cy.get('.sql-toolbar .label-success').then(node => {
- expect(parseClockStr(node)).greaterThan(0.9);
- });
- });
-
- it.skip('successfully saves a query', () => {
- cy.intercept('**/api/v1/database/**/tables/**').as('getTables');
-
- const query =
- 'SELECT ds, gender, name, num FROM main.birth_names ORDER BY name LIMIT 3';
- const savedQueryTitle = `CYPRESS TEST QUERY ${nanoid()}`;
-
- // we will assert that the results of the query we save, and the saved query are the same
- let initialResultsTable: HTMLElement | null = null;
- let savedQueryResultsTable = null;
-
- cy.get('#brace-editor textarea').clear({ force: true });
- cy.get('#brace-editor textarea').type(`{selectall}{backspace}${query}`, {
- force: true,
- });
- cy.get('#brace-editor textarea').focus(); // focus => blur is required for updating the query that is to be saved
- cy.focused().blur();
-
- // ctrl + r also runs query
- cy.get('#brace-editor textarea').type('{ctrl}r', { force: true });
-
- cy.wait('@sqlLabQuery');
-
- // Save results to check against below
- selectResultsTab().then(resultsA => {
- initialResultsTable = resultsA[0];
- });
-
- cy.get('#js-sql-toolbar button')
- .eq(1) // save query
- .click();
-
- // Enter name + save into modal
- cy.get('.modal-sm input').clear({ force: true });
- cy.get('.modal-sm input').type(`{selectall}{backspace}${savedQueryTitle}`, {
- force: true,
- });
-
- cy.get('.modal-sm .modal-body button')
- .eq(0) // save
- .click();
-
- // first row contains most recent link, follow back to SqlLab
- cy.get('table tr:first-child a[href*="savedQueryId"').click();
-
- // will timeout without explicitly waiting here
- cy.wait(['@getSavedQuery', '@getTables']);
-
- // run the saved query
- cy.get('#js-sql-toolbar button')
- .eq(0) // run query
- .click();
-
- cy.wait('@sqlLabQuery');
-
- // assert the results of the saved query match the initial results
- selectResultsTab().then(resultsB => {
- savedQueryResultsTable = resultsB[0];
-
- assertSQLLabResultsAreEqual(initialResultsTable, savedQueryResultsTable);
- });
- });
-
- it.skip('Create a chart from a query', () => {
- cy.intercept('**/api/v1/sqllab/execute/').as('queryFinished');
- cy.intercept('**/api/v1/explore/**').as('explore');
- cy.intercept('**/api/v1/chart/**').as('chart');
- cy.intercept('**/tabstateview/**').as('tabstateview');
-
- // cypress doesn't handle opening a new tab, override window.open to open in the same tab
- cy.window().then(win => {
- cy.stub(win, 'open', url => {
- // eslint-disable-next-line no-param-reassign
- win.location.href = url;
- });
- });
- cy.wait('@tabstateview');
-
- const query = 'SELECT gender, name FROM birth_names';
-
- cy.get('.ace_text-input').focus();
- cy.focused().clear({ force: true });
- cy.focused().type(`{selectall}{backspace}${query}`, { force: true });
- cy.get('.sql-toolbar button').contains('Run').click();
- cy.wait('@queryFinished');
-
- cy.get(
- '.SouthPane .ant-tabs-content > .ant-tabs-tabpane-active > div button:first',
- { timeout: 10000 },
- ).click();
-
- cy.wait('@explore');
- cy.get('[data-test="datasource-control"] .title-select').contains(query);
- cy.get('.column-option-label').first().contains('gender');
- cy.get('.column-option-label').last().contains('name');
-
- cy.get(
- '[data-test="all_columns"] [data-test="dnd-labels-container"] > div:first-child',
- ).contains('gender');
- cy.get(
- '[data-test="all_columns"] [data-test="dnd-labels-container"] > div:nth-child(2)',
- ).contains('name');
-
- cy.wait('@chart');
- cy.get('[data-test="slice-container"] table > thead th')
- .first()
- .contains('gender');
- cy.get('[data-test="slice-container"] table > thead th')
- .last()
- .contains('name');
- });
-});
diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/sqllab.helper.js b/superset-frontend/cypress-base/cypress/e2e/sqllab/sqllab.helper.js
deleted file mode 100644
index 45f298205d3..00000000000
--- a/superset-frontend/cypress-base/cypress/e2e/sqllab/sqllab.helper.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-export const selectResultsTab = () =>
- cy.get('.SouthPane .ReactVirtualized__Table', { timeout: 10000 });
-
-// this function asserts that the result set for two SQL lab table results are equal
-export const assertSQLLabResultsAreEqual = (resultsA, resultsB) => {
- const [headerA, bodyWrapperA] = resultsA.childNodes;
- const bodyA = bodyWrapperA.childNodes[0];
-
- const [headerB, bodyWrapperB] = resultsB.childNodes;
- const bodyB = bodyWrapperB.childNodes[0];
-
- expect(headerA.childNodes.length).to.equal(headerB.childNodes.length);
- expect(bodyA.childNodes.length).to.equal(bodyB.childNodes.length);
-
- bodyA.childNodes.forEach((rowA, rowIndex) => {
- const rowB = bodyB.childNodes[rowIndex];
-
- rowA.childNodes.forEach((cellA, columnIndex) => {
- const cellB = rowB.childNodes[columnIndex];
- expect(cellA.innerText).to.equal(cellB.innerText);
- });
- });
-};
diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts b/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts
deleted file mode 100644
index 8ca480aab6d..00000000000
--- a/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-describe('SqlLab query tabs', () => {
- beforeEach(() => {
- cy.visit('/sqllab');
- });
-
- const tablistSelector = '[data-test="sql-editor-tabs"] > [role="tablist"]';
- const tabSelector = `${tablistSelector} [role="tab"]:not([type="button"])`;
-
- it('allows you to create and close a tab', () => {
- cy.get(tabSelector).then(tabs => {
- const initialTabCount = tabs.length;
- const initialUntitledCount = Math.max(
- 0,
- ...tabs
- .map(
- (i, tabItem) =>
- Number(tabItem.textContent?.match(/Untitled Query (\d+)/)?.[1]) ||
- 0,
- )
- .toArray(),
- );
-
- // add two new tabs
- cy.get('[data-test="add-tab-icon"]:visible:last').click({ force: true });
- cy.contains('[role="tab"]', `Untitled Query ${initialUntitledCount + 1}`);
- cy.get(tabSelector).should('have.length', initialTabCount + 1);
-
- cy.get('[data-test="add-tab-icon"]:visible:last').click({ force: true });
- cy.contains('[role="tab"]', `Untitled Query ${initialUntitledCount + 2}`);
- cy.get(tabSelector).should('have.length', initialTabCount + 2);
-
- // close the tabs
- cy.get(`${tabSelector}:last [data-test="dropdown-trigger"]`).click({
- force: true,
- });
- cy.get('[data-test="close-tab-menu-option"]').click();
- cy.get(tabSelector).should('have.length', initialTabCount + 1);
- cy.contains('[role="tab"]', `Untitled Query ${initialUntitledCount + 1}`);
-
- cy.get(`${tablistSelector} [aria-label="remove"]:last`).click();
- cy.get(tabSelector).should('have.length', initialTabCount);
- });
- });
-
- it('opens a new tab by a button and a shortcut', () => {
- const editorContent = '.ace_editor .ace_content';
- const editorInput = '.ace_editor textarea';
- const queryLimitSelector = '#js-sql-toolbar .limitDropdown';
- cy.get(tabSelector).then(tabs => {
- const initialTabCount = tabs.length;
- const initialUntitledCount = Math.max(
- 0,
- ...tabs
- .map(
- (i, tabItem) =>
- Number(tabItem.textContent?.match(/Untitled Query (\d+)/)?.[1]) ||
- 0,
- )
- .toArray(),
- );
-
- // configure some editor settings
- cy.get(editorInput).type('some random query string', { force: true });
- cy.get(queryLimitSelector).parent().click({ force: true });
- cy.get('.ant-dropdown-menu')
- .last()
- .find('.ant-dropdown-menu-item')
- .first()
- .click({ force: true });
-
- // open a new tab by a button
- cy.get('[data-test="add-tab-icon"]:visible:last').click({ force: true });
- cy.contains('[role="tab"]', `Untitled Query ${initialUntitledCount + 1}`);
- cy.get(tabSelector).should('have.length', initialTabCount + 1);
- cy.get(editorContent).contains('SELECT ...');
- cy.get(queryLimitSelector).contains('10');
-
- // close the tab
- cy.get(`${tabSelector}:last [data-test="dropdown-trigger"]`).click({
- force: true,
- });
- cy.get(`${tablistSelector} [aria-label="remove"]:last`).click({
- force: true,
- });
- cy.get(tabSelector).should('have.length', initialTabCount);
-
- // open a new tab by a shortcut
- cy.get('body').type('{ctrl}t');
- cy.get(tabSelector).should('have.length', initialTabCount + 1);
- cy.contains('[role="tab"]', `Untitled Query ${initialUntitledCount + 1}`);
- cy.get(editorContent).contains('SELECT ...');
- cy.get(queryLimitSelector).contains('10');
- });
- });
-});
diff --git a/superset-frontend/playwright/components/core/AgGrid.ts b/superset-frontend/playwright/components/core/AgGrid.ts
new file mode 100644
index 00000000000..0c073222dfb
--- /dev/null
+++ b/superset-frontend/playwright/components/core/AgGrid.ts
@@ -0,0 +1,85 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Locator, Page } from '@playwright/test';
+
+const AG_GRID_SELECTORS = {
+ ROOT: '[role="grid"]',
+ HEADER_ROW: '.ag-header-row',
+ HEADER_CELL: '.ag-header-cell',
+ BODY_ROW: '.ag-row',
+ CELL: '.ag-cell',
+} as const;
+
+/**
+ * AG Grid component wrapper for Playwright.
+ * Used by FilterableTable/GridTable in SQL Lab results and elsewhere.
+ */
+export class AgGrid {
+ readonly page: Page;
+ private readonly locator: Locator;
+
+ constructor(page: Page, locator: Locator) {
+ this.page = page;
+ this.locator = locator;
+ }
+
+ get element(): Locator {
+ return this.locator;
+ }
+
+ /**
+ * Wait for the grid to render with data rows
+ */
+ async waitForRows(options?: { timeout?: number }): Promise<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() ?? '';
+ }
+}
diff --git a/superset-frontend/playwright/components/core/index.ts b/superset-frontend/playwright/components/core/index.ts
index 53a2ad71d6e..005917a19f1 100644
--- a/superset-frontend/playwright/components/core/index.ts
+++ b/superset-frontend/playwright/components/core/index.ts
@@ -19,6 +19,7 @@
// Core Playwright Components for Superset
export { AceEditor } from './AceEditor';
+export { AgGrid } from './AgGrid';
export { Button } from './Button';
export { Checkbox } from './Checkbox';
export { Form } from './Form';
diff --git a/superset-frontend/playwright/components/core/index.ts b/superset-frontend/playwright/helpers/api/savedQuery.ts
similarity index 52%
copy from superset-frontend/playwright/components/core/index.ts
copy to superset-frontend/playwright/helpers/api/savedQuery.ts
index 53a2ad71d6e..9c5a5a29d8b 100644
--- a/superset-frontend/playwright/components/core/index.ts
+++ b/superset-frontend/playwright/helpers/api/savedQuery.ts
@@ -17,16 +17,31 @@
* under the License.
*/
-// Core Playwright Components for Superset
-export { AceEditor } from './AceEditor';
-export { Button } from './Button';
-export { Checkbox } from './Checkbox';
-export { Form } from './Form';
-export { Input } from './Input';
-export { Menu } from './Menu';
-export { Modal } from './Modal';
-export { Select } from './Select';
-export { Table } from './Table';
-export { Tabs } from './Tabs';
-export { Textarea } from './Textarea';
-export { Toast } from './Toast';
+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);
+}
diff --git a/superset-frontend/playwright/helpers/fixtures/testAssets.ts b/superset-frontend/playwright/helpers/fixtures/testAssets.ts
index 7a4f0f10c07..b630bbe60ad 100644
--- a/superset-frontend/playwright/helpers/fixtures/testAssets.ts
+++ b/superset-frontend/playwright/helpers/fixtures/testAssets.ts
@@ -23,6 +23,7 @@ import { apiDeleteDashboard } from '../api/dashboard';
import { apiDeleteDataset } from '../api/dataset';
import { apiDeleteTheme } from '../api/theme';
import { apiDeleteDatabase } from '../api/database';
+import { apiDeleteSavedQuery } from '../api/savedQuery';
/**
* Test asset tracker for automatic cleanup after each test.
@@ -34,6 +35,7 @@ export interface TestAssets {
trackDataset(id: number): void;
trackTheme(id: number): void;
trackDatabase(id: number): void;
+ trackSavedQuery(id: number): void;
}
const EXPECTED_CLEANUP_STATUSES = new Set([200, 202, 204, 404]);
@@ -46,6 +48,7 @@ export const test = base.extend<{ testAssets: TestAssets }>({
const datasetIds = new Set<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(
diff --git a/superset-frontend/playwright/pages/AuthPage.ts b/superset-frontend/playwright/pages/AuthPage.ts
index 2cb5157e144..397e2a82df1 100644
--- a/superset-frontend/playwright/pages/AuthPage.ts
+++ b/superset-frontend/playwright/pages/AuthPage.ts
@@ -50,7 +50,9 @@ export class AuthPage {
* Navigate to the login page
*/
async goto(): Promise<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' });
}
/**
diff --git a/superset-frontend/playwright/pages/SqlLabPage.ts b/superset-frontend/playwright/pages/SqlLabPage.ts
new file mode 100644
index 00000000000..bfe60f9e1ba
--- /dev/null
+++ b/superset-frontend/playwright/pages/SqlLabPage.ts
@@ -0,0 +1,275 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Page, Locator } from '@playwright/test';
+import { AceEditor } from '../components/core/AceEditor';
+import { AgGrid } from '../components/core/AgGrid';
+import { Select } from '../components/core/Select';
+import { Modal } from '../components/core/Modal';
+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 static readonly SELECTORS = {
+ SQL_EDITOR_TABS: '[data-test="sql-editor-tabs"]',
+ TABLIST: '[data-test="sql-editor-tabs"] > [role="tablist"]',
+ TAB: '[data-test="sql-editor-tabs"] > [role="tablist"] [role="tab"]:not([type="button"])',
+ ADD_TAB_ICON: '[data-test="add-tab-icon"]',
+ DROPDOWN_TRIGGER: '[data-test="dropdown-trigger"]',
+ CLOSE_TAB_MENU_OPTION: '[data-test="close-tab-menu-option"]',
+ 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"]',
+ SAVE_QUERY_MODAL: '.save-query-modal',
+ ACE_EDITOR: '.ace_editor',
+ LEFT_BAR: '[data-test="sql-editor-left-bar"]',
+ DATABASE_SELECTOR: '[data-test="DatabaseSelector"]',
+ LIMIT_DROPDOWN: '.limitDropdown',
+ TAB_REMOVE: '[aria-label="remove"]',
+ } as const;
+
+ constructor(page: Page) {
+ this.page = page;
+ }
+
+ // ── 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.page
+ .locator(SqlLabPage.SELECTORS.SQL_EDITOR_TABS)
+ .waitFor({ state: 'visible', timeout });
+ }
+
+ /**
+ * 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.
+ */
+ async ensureEditorReady(): Promise<void> {
+ const editorLocator = this.page.locator(SqlLabPage.SELECTORS.ACE_EDITOR);
+ const editorCount = await editorLocator.count();
+ if (editorCount === 0) {
+ await this.addTab();
+ // Wait for new tab panel to render
+ await this.page.waitForTimeout(1000);
+ }
+ await this.getEditor().waitForReady();
+ }
+
+ // ── Active Tab Panel ──
+
+ /**
+ * Gets the active (visible) tab panel. SQL Lab can have multiple tab panels
+ * in the DOM; only the active one is visible. Scoping to this avoids
+ * strict mode violations from duplicate elements in inactive panels.
+ */
+ private get activePanel(): Locator {
+ return this.page
+ .locator('[role="tabpanel"]')
+ .filter({ has: this.page.locator(SqlLabPage.SELECTORS.ACE_EDITOR) })
+ .first();
+ }
+
+ // ── Editor ──
+
+ getEditor(): AceEditor {
+ return new AceEditor(
+ this.page,
+ this.activePanel.locator(SqlLabPage.SELECTORS.ACE_EDITOR),
+ );
+ }
+
+ async setQuery(sql: string): Promise<void> {
+ await this.getEditor().setText(sql);
+ }
+
+ async getQuery(): Promise<string> {
+ return this.getEditor().getText();
+ }
+
+ // ── Tab Management ──
+
+ private get tabs(): Locator {
+ return this.page.locator(SqlLabPage.SELECTORS.TAB);
+ }
+
+ async getTabCount(): Promise<number> {
+ return this.tabs.count();
+ }
+
+ async getTabNames(): Promise<string[]> {
+ return this.tabs.allTextContents();
+ }
+
+ async addTab(): Promise<void> {
+ // Target the visible "Add tab" button — there can be duplicates in the DOM
+ await this.page
+ .locator(SqlLabPage.SELECTORS.SQL_EDITOR_TABS)
+ .getByRole('button', { name: 'Add tab' })
+ .first()
+ .click();
+ }
+
+ 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();
+ // Click the × (close) button on the last tab.
+ await this.page
+ .locator(
+ `${SqlLabPage.SELECTORS.TABLIST} ${SqlLabPage.SELECTORS.TAB_REMOVE}`,
+ )
+ .last()
+ .click();
+ // Wait for tab count to decrease
+ const tabSelector = SqlLabPage.SELECTORS.TAB;
+ await this.page.waitForFunction(
+ ([sel, expected]) => document.querySelectorAll(sel).length === expected,
+ [tabSelector, countBefore - 1] as const,
+ { timeout: 5000 },
+ );
+ }
+
+ getTab(name: string): Locator {
+ return this.page.locator('[role="tab"]', { hasText: name });
+ }
+
+ // ── Database Selection (Left Sidebar) ──
+
+ async selectDatabase(dbName: string): Promise<void> {
+ // Click the DatabaseSelector in sqlLabMode to open the popover
+ await this.page
+ .locator(
+ `${SqlLabPage.SELECTORS.LEFT_BAR} ${SqlLabPage.SELECTORS.DATABASE_SELECTOR}`,
+ )
+ .click();
+
+ // Wait for the popover to appear
+ const popover = this.page.locator('.ant-popover-content');
+ await popover.waitFor({ state: 'visible' });
+
+ // Inside the popover, select the database from the dropdown.
+ // Target the .ant-select wrapper (not the combobox input) because the
+ // selection-item overlay intercepts pointer events on the input.
+ const dbSelect = popover
+ .locator(SqlLabPage.SELECTORS.DATABASE_SELECTOR)
+ .locator('.ant-select')
+ .first();
+ const popoverSelector = new Select(this.page, dbSelect);
+ await popoverSelector.selectOption(dbName);
+
+ // Click the "Select" button to confirm
+ await popover.getByRole('button', { name: 'Select', exact: true }).click();
+
+ // Wait for popover to close
+ await popover.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
+ }
+
+ getDatabaseSelectorText(): Locator {
+ return this.page.locator(
+ `${SqlLabPage.SELECTORS.LEFT_BAR} ${SqlLabPage.SELECTORS.DATABASE_SELECTOR}`,
+ );
+ }
+
+ // ── Query Execution ──
+
+ async runQuery(): Promise<void> {
+ await this.activePanel
+ .locator(SqlLabPage.SELECTORS.RUN_QUERY_BUTTON)
+ .click();
+ }
+
+ async waitForQueryResults(options?: { timeout?: number }): Promise<void> {
+ const timeout = options?.timeout ?? TIMEOUT.QUERY_EXECUTION;
+ await this.getResultsGrid().element.waitFor({
+ state: 'visible',
+ timeout,
+ });
+ }
+
+ /**
+ * Returns the AG Grid component in the results pane.
+ * Use this to inspect headers, rows, and cell values.
+ */
+ getResultsGrid(): AgGrid {
+ return new AgGrid(
+ this.page,
+ this.page
+ .locator(SqlLabPage.SELECTORS.SOUTH_PANE)
+ .locator('[role="grid"]'),
+ );
+ }
+
+ getResultsPane(): Locator {
+ return this.page.locator(SqlLabPage.SELECTORS.SOUTH_PANE);
+ }
+
+ // ── Row Limit ──
+
+ async getRowLimit(): Promise<string> {
+ // Scope to active tab panel to avoid strict mode violation
+ // when multiple tabs have limitDropdown elements
+ const text = await this.page
+ .locator('[role="tabpanel"]')
+ .filter({ has: this.page.locator(SqlLabPage.SELECTORS.ACE_EDITOR) })
+ .locator(SqlLabPage.SELECTORS.LIMIT_DROPDOWN)
+ .first()
+ .textContent();
+ return text?.trim() ?? '';
+ }
+
+ // ── Save Query ──
+
+ async clickSaveButton(): Promise<void> {
+ await this.activePanel.locator(SqlLabPage.SELECTORS.SAVE_BUTTON).click();
+ }
+
+ getSaveQueryModal(): Modal {
+ return new Modal(this.page, SqlLabPage.SELECTORS.SAVE_QUERY_MODAL);
+ }
+
+ // ── Create Chart ──
+
+ getCreateChartButton(): Locator {
+ return this.activePanel.locator(
+ SqlLabPage.SELECTORS.EXPLORE_RESULTS_BUTTON,
+ );
+ }
+
+ async clickCreateChart(): Promise<void> {
+ await this.getCreateChartButton().click();
+ }
+}
diff --git a/superset-frontend/playwright/tests/sqllab/create-chart-from-query.spec.ts b/superset-frontend/playwright/tests/sqllab/create-chart-from-query.spec.ts
new file mode 100644
index 00000000000..7f603fe4402
--- /dev/null
+++ b/superset-frontend/playwright/tests/sqllab/create-chart-from-query.spec.ts
@@ -0,0 +1,68 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { test, expect } from '@playwright/test';
+import { SqlLabPage } from '../../pages/SqlLabPage';
+import { ExplorePage } from '../../pages/ExplorePage';
+import { waitForPost } from '../../helpers/api/intercepts';
+import { TIMEOUT } from '../../utils/constants';
+
+test('should navigate to Explore from SQL Lab query results', async ({
+ page,
+}) => {
+ const sqlLabPage = new SqlLabPage(page);
+ await sqlLabPage.goto();
+ await sqlLabPage.waitForPageLoad();
+ await sqlLabPage.ensureEditorReady();
+
+ // Select database via left sidebar — this triggers the database list API fetch
+ // which populates the Redux store with allows_subquery (needed for Create chart button)
+ await sqlLabPage.selectDatabase('examples');
+
+ const query = 'SELECT gender, name FROM birth_names';
+ await sqlLabPage.setQuery(query);
+
+ const executePromise = waitForPost(page, 'api/v1/sqllab/execute/', {
+ timeout: TIMEOUT.QUERY_EXECUTION,
+ });
+ await sqlLabPage.runQuery();
+ const executeResponse = await executePromise;
+
+ // Skip test if birth_names table doesn't exist (sample data not loaded)
+ if (executeResponse.status() !== 200) {
+ test.skip(true, 'birth_names table not available — sample data not loaded');
+ }
+
+ await sqlLabPage.waitForQueryResults();
+
+ // "Create chart" should be enabled when database.allows_subquery is true.
+ // If this fails, the database configuration is broken.
+ await expect(sqlLabPage.getCreateChartButton()).toBeEnabled({
+ timeout: 10000,
+ });
+ await sqlLabPage.clickCreateChart();
+
+ // Wait for navigation to Explore page
+ const explorePage = new ExplorePage(page);
+ await explorePage.waitForPageLoad({ timeout: TIMEOUT.PAGE_LOAD });
+
+ // Verify the datasource control shows the query
+ const datasetName = await explorePage.getDatasetName();
+ expect(datasetName).toContain(query);
+});
diff --git a/superset-frontend/playwright/tests/sqllab/query-execution.spec.ts b/superset-frontend/playwright/tests/sqllab/query-execution.spec.ts
new file mode 100644
index 00000000000..2ccfa0ed392
--- /dev/null
+++ b/superset-frontend/playwright/tests/sqllab/query-execution.spec.ts
@@ -0,0 +1,110 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { test, expect } from '../../helpers/fixtures/testAssets';
+import { SqlLabPage } from '../../pages/SqlLabPage';
+import { waitForPost } from '../../helpers/api/intercepts';
+import { apiGetSavedQuery } from '../../helpers/api/savedQuery';
+import { TIMEOUT } from '../../utils/constants';
+
+let sqlLabPage: SqlLabPage;
+
+test.beforeEach(async ({ page }) => {
+ sqlLabPage = new SqlLabPage(page);
+ await sqlLabPage.goto();
+ await sqlLabPage.waitForPageLoad();
+ await sqlLabPage.ensureEditorReady();
+});
+
+test('should execute a query and display results', async ({ page }) => {
+ // Verify the left sidebar database selector is visible and interactive (#38833)
+ await expect(sqlLabPage.getDatabaseSelectorText()).toBeVisible();
+
+ // Set the query
+ await sqlLabPage.setQuery('SELECT 1 AS test_col');
+
+ // Run query and wait for API response
+ const executePromise = waitForPost(page, 'api/v1/sqllab/execute/', {
+ timeout: TIMEOUT.QUERY_EXECUTION,
+ });
+ await sqlLabPage.runQuery();
+ const response = await executePromise;
+ expect(response.status()).toBe(200);
+
+ // Verify results appear in the AG Grid
+ await sqlLabPage.waitForQueryResults();
+ const grid = sqlLabPage.getResultsGrid();
+ const headers = await grid.getHeaderTexts();
+ expect(headers.some(h => h.includes('test_col'))).toBe(true);
+});
+
+test('should save and reload a query', async ({ page, testAssets }) => {
+ const queryText = 'SELECT 1 AS saved_test_col';
+ const savedQueryTitle = `pw_test_saved_query_${Date.now()}`;
+
+ // Verify left sidebar is interactive
+ await expect(sqlLabPage.getDatabaseSelectorText()).toBeVisible();
+
+ // Set and run query
+ await sqlLabPage.setQuery(queryText);
+
+ const executePromise = waitForPost(page, 'api/v1/sqllab/execute/', {
+ timeout: TIMEOUT.QUERY_EXECUTION,
+ });
+ await sqlLabPage.runQuery();
+ await executePromise;
+ await sqlLabPage.waitForQueryResults();
+
+ // Open the save query modal
+ await sqlLabPage.clickSaveButton();
+ const saveModal = sqlLabPage.getSaveQueryModal();
+ await saveModal.waitForReady();
+
+ // Fill in the query name
+ await saveModal.body.locator('input[type="text"]').first().clear();
+ await saveModal.body
+ .locator('input[type="text"]')
+ .first()
+ .fill(savedQueryTitle);
+
+ // Save and intercept the API response
+ const savePromise = waitForPost(page, 'api/v1/saved_query/', {
+ timeout: TIMEOUT.API_RESPONSE,
+ });
+ await saveModal.footer
+ .getByRole('button', { name: 'Save', exact: true })
+ .click();
+ const saveResponse = await savePromise;
+ expect(saveResponse.status()).toBe(201);
+
+ // Extract saved query ID for cleanup
+ const saveBody = await saveResponse.json();
+ const savedQueryId: number = saveBody.id ?? saveBody.result?.id;
+ expect(savedQueryId).toBeTruthy();
+ testAssets.trackSavedQuery(savedQueryId);
+
+ // Verify the modal closed
+ await saveModal.waitForHidden();
+
+ // Verify the saved query via API (round-trip persistence check)
+ const getResponse = await apiGetSavedQuery(page, savedQueryId);
+ const savedQuery = (await getResponse.json()).result;
+ expect(savedQuery.sql).toContain('saved_test_col');
+ expect(savedQuery.label).toBe(savedQueryTitle);
+});
diff --git a/superset-frontend/playwright/tests/sqllab/tabs.spec.ts b/superset-frontend/playwright/tests/sqllab/tabs.spec.ts
new file mode 100644
index 00000000000..a687815ebb1
--- /dev/null
+++ b/superset-frontend/playwright/tests/sqllab/tabs.spec.ts
@@ -0,0 +1,103 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { test, expect } from '@playwright/test';
+import { SqlLabPage } from '../../pages/SqlLabPage';
+import { waitForPost } from '../../helpers/api/intercepts';
+
+let sqlLabPage: SqlLabPage;
+
+test.beforeEach(async ({ page }) => {
+ sqlLabPage = new SqlLabPage(page);
+ await sqlLabPage.goto();
+ await sqlLabPage.waitForPageLoad();
+ await sqlLabPage.ensureEditorReady();
+});
+
+test('should create tabs that persist across page reload', async ({
+ page,
+}) => {
+ const initialTabCount = await sqlLabPage.getTabCount();
+
+ // Create a new tab and verify count incremented
+ await sqlLabPage.addTab();
+ await sqlLabPage.getEditor().waitForReady();
+ expect(await sqlLabPage.getTabCount()).toBe(initialTabCount + 1);
+
+ // Set up tab state intercept before making changes (avoid race with debounced save)
+ const tabStatePromise = waitForPost(page, 'tabstateview');
+
+ // Type a distinctive query so we can verify persistence
+ const testQuery = `SELECT 'persistence_test_${Date.now()}'`;
+ await sqlLabPage.setQuery(testQuery);
+
+ // Trigger a save by blurring the editor (tab state saves on editor changes)
+ await page.locator('body').click();
+ await tabStatePromise;
+
+ // Reload the page
+ await page.reload();
+ await sqlLabPage.waitForPageLoad();
+ await sqlLabPage.ensureEditorReady();
+
+ // Verify the tab survived the reload
+ expect(await sqlLabPage.getTabCount()).toBe(initialTabCount + 1);
+
+ // Verify the editor content survived the reload
+ const restoredQuery = await sqlLabPage.getQuery();
+ expect(restoredQuery).toContain('persistence_test_');
+
+ // Clean up: close the tab we created
+ await sqlLabPage.closeLastTab();
+ expect(await sqlLabPage.getTabCount()).toBe(initialTabCount);
+});
+
+test('should open new tab by keyboard shortcut with correct defaults', async ({
+ page,
+}) => {
+ const initialTabCount = await sqlLabPage.getTabCount();
+
+ // Type something in the current editor to differentiate from default state
+ await sqlLabPage.setQuery('some random query string');
+
+ // Open new tab via keyboard shortcut
+ await sqlLabPage.addTabByShortcut();
+ await sqlLabPage.getEditor().waitForReady();
+ expect(await sqlLabPage.getTabCount()).toBe(initialTabCount + 1);
+
+ // Verify new tab has default editor content
+ const defaultContent = await sqlLabPage.getQuery();
+ expect(defaultContent).toContain('SELECT');
+
+ // Set up tab state intercept before triggering save (avoid race with debounced save)
+ const tabStatePromise = waitForPost(page, 'tabstateview');
+ await page.locator('body').click();
+ await tabStatePromise;
+
+ await page.reload();
+ await sqlLabPage.waitForPageLoad();
+ await sqlLabPage.ensureEditorReady();
+
+ // Verify the new tab persisted after reload
+ expect(await sqlLabPage.getTabCount()).toBe(initialTabCount + 1);
+
+ // Clean up: close the new tab
+ await sqlLabPage.closeLastTab();
+ expect(await sqlLabPage.getTabCount()).toBe(initialTabCount);
+});
diff --git a/superset-frontend/playwright/utils/constants.ts b/superset-frontend/playwright/utils/constants.ts
index 7f882d9630d..b6b78cca7f2 100644
--- a/superset-frontend/playwright/utils/constants.ts
+++ b/superset-frontend/playwright/utils/constants.ts
@@ -58,4 +58,9 @@ export const TIMEOUT = {
* File import/upload operations (upload + server processing)
*/
FILE_IMPORT: 30000, // 30s for file import operations
+
+ /**
+ * SQL query execution (query → backend processing → results)
+ */
+ QUERY_EXECUTION: 15000, // 15s for SQL queries that may take longer than default expect timeout
} as const;