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;