This is an automated email from the ASF dual-hosted git repository.

sadpandajoe pushed a commit to branch remove-explore-cypress-test
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 409b359c40b7f4b8deb389798b5fa8d8a22555ef
Author: Joe Li <[email protected]>
AuthorDate: Thu Sep 24 15:10:36 2026 -0700

    test(explore): migrate remaining explore e2e coverage to Playwright
    
    Port the last Cypress explore scenarios to Playwright using the page-object
    model: save-as/overwrite (with and without adding to a new dashboard), the
    "No results" state, and the chart metadata bar / "On dashboards" submenu
    reflecting dashboard membership. Adds a SaveChartModal component, Explore 
page
    helpers, and chart/dashboard name-lookup API helpers, and removes the
    superseded Cypress spec and helpers.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../cypress-base/cypress/e2e/explore/chart.test.js | 157 -----------------
 .../cypress-base/cypress/e2e/explore/utils.ts      |  78 ---------
 .../e2e/explore/visualizations/shared.helper.js    | 139 ---------------
 .../playwright/components/modals/SaveChartModal.ts |  90 ++++++++++
 .../playwright/components/modals/index.ts          |   1 +
 superset-frontend/playwright/helpers/api/chart.ts  |  44 +++++
 .../playwright/helpers/api/dashboard.ts            |  37 ++++
 superset-frontend/playwright/pages/ExplorePage.ts  |  78 +++++++++
 .../explore/cross-referenced-dashboards.spec.ts    | 191 +++++++++++++++++++++
 .../tests/explore/explore-no-results.spec.ts       |  87 ++++++++++
 .../playwright/tests/explore/explore-save.spec.ts  | 179 +++++++++++++++++++
 11 files changed, 707 insertions(+), 374 deletions(-)

diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js 
b/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js
deleted file mode 100644
index 16cac29a07d..00000000000
--- a/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js
+++ /dev/null
@@ -1,157 +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 { CHART_LIST } from 'cypress/utils/urls';
-import { FORM_DATA_DEFAULTS, NUM_METRIC } from 
'./visualizations/shared.helper';
-import {
-  interceptDashboardGet,
-  interceptFiltering,
-  interceptV1ChartData,
-  saveChartToDashboard,
-  visitSampleChartFromList,
-} from './utils';
-
-// SEARCH_THRESHOLD is 10. We need to add at least 11 dashboards to show search
-const SAMPLE_DASHBOARDS_INDEXES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
-
-function openDashboardsAddedTo() {
-  cy.getBySel('actions-trigger').should('be.visible').click();
-  cy.get('.ant-dropdown-menu-submenu-title')
-    .contains('On dashboards')
-    .trigger('mouseover', { force: true });
-}
-
-function closeDashboardsAddedTo() {
-  cy.get('.ant-dropdown-menu-submenu-title')
-    .contains('On dashboards')
-    .trigger('mouseout', { force: true });
-  cy.getBySel('actions-trigger').click();
-}
-
-function verifyDashboardsSubmenuItem(dashboardName) {
-  cy.get('.ant-dropdown-menu-submenu-popup').contains(dashboardName);
-  closeDashboardsAddedTo();
-}
-
-function verifyDashboardSearch() {
-  openDashboardsAddedTo();
-  cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
-  cy.get('.ant-dropdown-menu-submenu-popup')
-    .find('input[placeholder="Search"]')
-    .type('1');
-  cy.get('.ant-dropdown-menu-submenu-popup').contains('1 - Sample dashboard');
-  cy.get('.ant-dropdown-menu-submenu-popup')
-    .find('input[placeholder="Search"]')
-    .type('Blahblah');
-  cy.get('.ant-dropdown-menu-submenu-popup').contains('No results found');
-  cy.get('.ant-dropdown-menu-submenu-popup')
-    .find('[aria-label="close-circle"]')
-    .click();
-  closeDashboardsAddedTo();
-}
-
-function verifyDashboardLink() {
-  interceptDashboardGet();
-  openDashboardsAddedTo();
-  cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover', {
-    force: true,
-  });
-  cy.get('.ant-dropdown-menu-submenu-popup a')
-    .first()
-    .invoke('removeAttr', 'target')
-    .click({ force: true });
-  cy.wait('@get');
-}
-
-function verifyMetabar(text) {
-  cy.getBySel('metadata-bar').contains(text);
-}
-
-function saveAndVerifyDashboard(chartName, number) {
-  saveChartToDashboard(chartName, `${number} - Sample dashboard`);
-  verifyMetabar(
-    number > 1 ? `Added to ${number} dashboards` : 'Added to 1 dashboard',
-  );
-  openDashboardsAddedTo();
-  verifyDashboardsSubmenuItem(`${number} - Sample dashboard`);
-}
-
-describe('Cross-referenced dashboards', () => {
-  beforeEach(() => {
-    interceptFiltering();
-
-    cy.createSampleDashboards(SAMPLE_DASHBOARDS_INDEXES);
-    cy.createSampleCharts([0]);
-    cy.visit(CHART_LIST);
-    cy.wait('@filtering');
-  });
-
-  it('should show the cross-referenced dashboards', () => {
-    visitSampleChartFromList('1 - Sample chart');
-
-    cy.getBySel('metadata-bar').contains('Not added to any dashboard');
-    openDashboardsAddedTo();
-    verifyDashboardsSubmenuItem('None');
-
-    saveAndVerifyDashboard('1 - Sample chart', '1');
-    saveAndVerifyDashboard('1 - Sample chart', '2');
-    saveAndVerifyDashboard('1 - Sample chart', '3');
-    saveAndVerifyDashboard('1 - Sample chart', '4');
-    saveAndVerifyDashboard('1 - Sample chart', '5');
-    saveAndVerifyDashboard('1 - Sample chart', '6');
-    saveAndVerifyDashboard('1 - Sample chart', '7');
-    saveAndVerifyDashboard('1 - Sample chart', '8');
-    saveAndVerifyDashboard('1 - Sample chart', '9');
-    saveAndVerifyDashboard('1 - Sample chart', '10');
-    saveAndVerifyDashboard('1 - Sample chart', '11');
-
-    verifyDashboardSearch();
-    verifyDashboardLink();
-  });
-});
-
-// Skip: Uses hardcoded datasource ID that may not exist after example loading 
changes
-describe.skip('No Results', () => {
-  beforeEach(() => {
-    interceptV1ChartData();
-  });
-
-  it('No results message shows up', () => {
-    const formData = {
-      ...FORM_DATA_DEFAULTS,
-      metrics: [NUM_METRIC],
-      viz_type: 'echarts_timeseries_line',
-      adhoc_filters: [
-        {
-          expressionType: 'SIMPLE',
-          subject: 'state',
-          operator: 'IN',
-          comparator: ['Fake State'],
-          clause: 'WHERE',
-          sqlExpression: null,
-        },
-      ],
-    };
-
-    cy.visitChartByParams(formData);
-    cy.wait('@v1Data').its('response.statusCode').should('eq', 200);
-    cy.get('div.chart-container').contains(
-      'No results were returned for this query',
-    );
-  });
-});
diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/utils.ts 
b/superset-frontend/cypress-base/cypress/e2e/explore/utils.ts
deleted file mode 100644
index 36884392c02..00000000000
--- a/superset-frontend/cypress-base/cypress/e2e/explore/utils.ts
+++ /dev/null
@@ -1,78 +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 function interceptDashboardGet() {
-  cy.intercept('GET', '**/api/v1/dashboard/*').as('get');
-}
-
-export function interceptFiltering() {
-  cy.intercept('GET', `**/api/v1/chart/?q=*`).as('filtering');
-}
-
-export function interceptUpdate() {
-  cy.intercept('PUT', `**/api/v1/chart/*`).as('update');
-}
-
-export const interceptV1ChartData = (alias = 'v1Data') => {
-  cy.intercept('**/api/v1/chart/data*').as(alias);
-};
-
-function interceptExploreGet() {
-  cy.intercept({
-    method: 'GET',
-    url: 
/.*\/api\/v1\/explore\/\?(form_data_key|dashboard_page_id|slice_id)=.*/,
-  }).as('getExplore');
-}
-
-export function saveChartToDashboard(chartName: string, dashboardName: string) 
{
-  interceptDashboardGet();
-  interceptUpdate();
-  interceptExploreGet();
-
-  cy.getBySel('query-save-button')
-    .should('be.enabled')
-    .should('not.be.disabled')
-    .click({ force: true });
-
-  cy.getBySel('save-modal-body')
-    .should('be.visible')
-    .then($modal => {
-      cy.wait(500);
-      cy.wrap($modal)
-        .find('.ant-select-input[aria-label*="Select a dashboard"]')
-        .type(dashboardName, { force: true });
-
-      cy.get(`.ant-select-item-option[title="${dashboardName}"]`).click();
-
-      cy.getBySel('btn-modal-save').click();
-      cy.wait('@update');
-    });
-  cy.getBySel('save-modal-body').should('not.exist');
-  cy.getBySel('query-save-button').should('be.disabled');
-  cy.wait('@get');
-  cy.wait('@getExplore');
-  cy.contains(`was added to dashboard [${dashboardName}]`);
-  cy.contains(`Chart [${chartName}] has been overwritten`);
-  cy.getBySel('query-save-button').should('be.enabled');
-}
-
-export function visitSampleChartFromList(chartName: string) {
-  cy.getBySel('table-row').contains(chartName).click();
-  cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
-}
diff --git 
a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/shared.helper.js
 
b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/shared.helper.js
deleted file mode 100644
index 12126e6a7b1..00000000000
--- 
a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/shared.helper.js
+++ /dev/null
@@ -1,139 +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.
- */
-// ***********************************************
-// Constants for visualization tests
-// ***********************************************
-
-/**
- * Look up a dataset ID by table name
- * @param {string} tableName - The name of the table to look up
- * @returns {Cypress.Chainable<number>} - The dataset ID
- */
-export function getDatasetId(tableName) {
-  return cy
-    .request({
-      method: 'GET',
-      url: `/api/v1/dataset/?q=${encodeURIComponent(
-        JSON.stringify({
-          filters: [{ col: 'table_name', opr: 'eq', value: tableName }],
-        }),
-      )}`,
-    })
-    .then(response => {
-      const datasets = response.body.result;
-      if (datasets && datasets.length > 0) {
-        return datasets[0].id;
-      }
-      throw new Error(`Dataset with table name "${tableName}" not found`);
-    });
-}
-
-export const FORM_DATA_DEFAULTS = {
-  datasource: '3__table',
-  time_grain_sqla: null,
-  x_axis: 'ds',
-  adhoc_filters: [
-    {
-      clause: 'WHERE',
-      subject: 'ds',
-      operator: 'TEMPORAL_RANGE',
-      comparator: '100 years ago : now',
-      expressionType: 'SIMPLE',
-    },
-  ],
-  groupby: [],
-  limit: null,
-  timeseries_limit_metric: null,
-  order_desc: false,
-  contributionMode: null,
-};
-
-export const HEALTH_POP_FORM_DATA_DEFAULTS = {
-  datasource: '2__table',
-  granularity_sqla: 'ds',
-  time_grain_sqla: 'P1D',
-  time_range: '1960-01-01 : 2014-01-02',
-};
-
-export const NUM_METRIC = {
-  expressionType: 'SIMPLE',
-  column: {
-    id: 336,
-    column_name: 'num',
-    verbose_name: null,
-    description: null,
-    expression: '',
-    filterable: false,
-    groupby: false,
-    is_dttm: false,
-    type: 'BIGINT',
-    database_expression: null,
-    python_date_format: null,
-    optionName: '_col_num',
-  },
-  aggregate: 'SUM',
-  sqlExpression: null,
-  hasCustomLabel: false,
-  label: 'Sum(num)',
-  optionName: 'metric_1de0s4viy5d_ly7y8k6ghvk',
-};
-
-export const MAX_DS = {
-  aggregate: 'MAX',
-  column: {
-    column_name: 'ds',
-    description: null,
-    expression: null,
-    filterable: true,
-    groupby: true,
-    id: 333,
-    is_dttm: true,
-    optionName: '_col_ds',
-    python_date_format: null,
-    type: 'TIMESTAMP WITHOUT TIME ZONE',
-    verbose_name: null,
-  },
-  expressionType: 'SIMPLE',
-  hasCustomLabel: false,
-  isNew: false,
-  label: 'MAX(ds)',
-  optionName: 'metric_pbib7j9m15a_js80vs9vca',
-  sqlExpression: null,
-};
-
-export const MAX_STATE = {
-  expressionType: 'SQL',
-  sqlExpression: 'MAX(UPPER(state))',
-  column: null,
-  aggregate: null,
-  isNew: false,
-  hasCustomLabel: false,
-  label: 'MAX(UPPER(state))',
-  optionName: 'metric_kvval50pvbo_hewj3pzacb',
-};
-
-export const SIMPLE_FILTER = {
-  expressionType: 'SIMPLE',
-  subject: 'name',
-  operator: 'IN',
-  comparator: ['Aaron', 'Amy', 'Andrea'],
-  clause: 'WHERE',
-  sqlExpression: null,
-  filterOptionName: 'filter_4y6teao56zs_ebjsvwy48c',
-};
diff --git a/superset-frontend/playwright/components/modals/SaveChartModal.ts 
b/superset-frontend/playwright/components/modals/SaveChartModal.ts
new file mode 100644
index 00000000000..5bd6582291a
--- /dev/null
+++ b/superset-frontend/playwright/components/modals/SaveChartModal.ts
@@ -0,0 +1,90 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Page } from '@playwright/test';
+import { Modal, Select } from '../core';
+
+/**
+ * Save chart modal in Explore.
+ * Opened by clicking the "Save" button (`query-save-button`) in the chart 
header.
+ * Supports overwriting the current chart, saving as a new chart, and adding 
the
+ * chart to an existing or brand-new dashboard.
+ */
+export class SaveChartModal extends Modal {
+  private static readonly SELECTORS = {
+    OVERWRITE_RADIO: '[data-test="save-overwrite-radio"]',
+    SAVEAS_RADIO: '[data-test="saveas-radio"]',
+    NAME_INPUT: '[data-test="new-chart-name"]',
+    // The AsyncSelect forwards `data-test={ariaLabel || name}`; SaveModal 
passes
+    // ariaLabel={t('Select a dashboard')}, and this stays stable regardless of
+    // whether a dashboard is currently selected.
+    DASHBOARD_SELECT: '[data-test="Select a dashboard"]',
+  } as const;
+
+  constructor(page: Page) {
+    // The modal itself carries no data-test; scope by its body's data-test 
instead.
+    super(page, '[role="dialog"]:has([data-test="save-modal-body"])');
+  }
+
+  /**
+   * Selects the "Save (Overwrite)" radio option.
+   */
+  async selectOverwrite(): Promise<void> {
+    await this.body.locator(SaveChartModal.SELECTORS.OVERWRITE_RADIO).click();
+  }
+
+  /**
+   * Selects the "Save as..." radio option.
+   */
+  async selectSaveAsNew(): Promise<void> {
+    await this.body.locator(SaveChartModal.SELECTORS.SAVEAS_RADIO).click();
+  }
+
+  /**
+   * Fills the chart name field (only meaningful in "Save as..." mode).
+   */
+  async fillChartName(name: string): Promise<void> {
+    const input = this.body.locator(SaveChartModal.SELECTORS.NAME_INPUT);
+    await input.fill('');
+    await input.fill(name);
+  }
+
+  private get dashboardSelect(): Select {
+    return new Select(
+      this.page,
+      this.body.locator(SaveChartModal.SELECTORS.DASHBOARD_SELECT),
+    );
+  }
+
+  /**
+   * Adds the chart to a dashboard. When `dashboardTitle` matches an existing
+   * dashboard it is selected; otherwise the select's `allowNewOptions`
+   * affordance creates a new dashboard with that title.
+   */
+  async selectDashboard(dashboardTitle: string): Promise<void> {
+    await this.dashboardSelect.selectOption(dashboardTitle);
+  }
+
+  /**
+   * Clicks the primary "Save" footer button (`btn-modal-save`).
+   */
+  async clickSave(): Promise<void> {
+    await this.clickFooterButton('Save');
+  }
+}
diff --git a/superset-frontend/playwright/components/modals/index.ts 
b/superset-frontend/playwright/components/modals/index.ts
index 09670d711e2..3d5a2a2523b 100644
--- a/superset-frontend/playwright/components/modals/index.ts
+++ b/superset-frontend/playwright/components/modals/index.ts
@@ -26,5 +26,6 @@ export { DuplicateDatasetModal } from 
'./DuplicateDatasetModal';
 export { EditDatasetModal } from './EditDatasetModal';
 export { ImportDatasetModal } from './ImportDatasetModal';
 export { NativeFiltersConfigModal } from './NativeFiltersConfigModal';
+export { SaveChartModal } from './SaveChartModal';
 export { SaveDatasetModal } from './SaveDatasetModal';
 export { SaveQueryModal } from './SaveQueryModal';
diff --git a/superset-frontend/playwright/helpers/api/chart.ts 
b/superset-frontend/playwright/helpers/api/chart.ts
index 090c8ad7586..aa3c9f5c353 100644
--- a/superset-frontend/playwright/helpers/api/chart.ts
+++ b/superset-frontend/playwright/helpers/api/chart.ts
@@ -18,6 +18,7 @@
  */
 
 import { Page, APIResponse } from '@playwright/test';
+import rison from 'rison';
 import {
   apiGet,
   apiPost,
@@ -102,3 +103,46 @@ export async function apiPutChart(
 ): Promise<APIResponse> {
   return apiPut(page, `${ENDPOINTS.CHART}${chartId}`, data, options);
 }
+
+/**
+ * TypeScript interface for a chart search result.
+ */
+export interface ChartResult {
+  id: number;
+  slice_name: string;
+}
+
+/**
+ * Result of {@link getChartsByName}: the total count and matching rows,
+ * mirroring the API's own `{count, result}` envelope for exact-count
+ * assertions (e.g. "exactly one chart named X exists").
+ */
+export interface ChartsByNameResult {
+  count: number;
+  result: ChartResult[];
+}
+
+/**
+ * Get every chart with an exact `slice_name` match.
+ * @param page - Playwright page instance (provides authentication context)
+ * @param name - The slice_name to search for
+ * @returns The matching charts and their total count
+ */
+export async function getChartsByName(
+  page: Page,
+  name: string,
+): Promise<ChartsByNameResult> {
+  const queryParam = rison.encode({
+    filters: [{ col: 'slice_name', opr: 'eq', value: name }],
+  });
+  const response = await apiGet(page, `${ENDPOINTS.CHART}?q=${queryParam}`, {
+    failOnStatusCode: false,
+  });
+
+  if (!response.ok()) {
+    return { count: 0, result: [] };
+  }
+
+  const body = await response.json();
+  return { count: body.count ?? 0, result: body.result ?? [] };
+}
diff --git a/superset-frontend/playwright/helpers/api/dashboard.ts 
b/superset-frontend/playwright/helpers/api/dashboard.ts
index 77028805cd4..d696518d30e 100644
--- a/superset-frontend/playwright/helpers/api/dashboard.ts
+++ b/superset-frontend/playwright/helpers/api/dashboard.ts
@@ -291,3 +291,40 @@ export async function getDashboardBySlug(
 ): Promise<DashboardResult | null> {
   return getDashboardByFilter(page, 'slug', slug);
 }
+
+/**
+ * Result of {@link getDashboardsByName}: the total count and matching rows,
+ * mirroring the API's own `{count, result}` envelope for exact-count
+ * assertions (e.g. "exactly one dashboard titled X exists").
+ */
+export interface DashboardsByNameResult {
+  count: number;
+  result: DashboardResult[];
+}
+
+/**
+ * Get every dashboard with an exact `dashboard_title` match.
+ * @param page - Playwright page instance (provides authentication context)
+ * @param title - The dashboard_title to search for
+ * @returns The matching dashboards and their total count
+ */
+export async function getDashboardsByName(
+  page: Page,
+  title: string,
+): Promise<DashboardsByNameResult> {
+  const queryParam = rison.encode({
+    filters: [{ col: 'dashboard_title', opr: 'eq', value: title }],
+  });
+  const response = await apiGet(
+    page,
+    `${ENDPOINTS.DASHBOARD}?q=${queryParam}`,
+    { failOnStatusCode: false },
+  );
+
+  if (!response.ok()) {
+    return { count: 0, result: [] };
+  }
+
+  const body = await response.json();
+  return { count: body.count ?? 0, result: body.result ?? [] };
+}
diff --git a/superset-frontend/playwright/pages/ExplorePage.ts 
b/superset-frontend/playwright/pages/ExplorePage.ts
index 39f6f5df0ae..6eab2e9afea 100644
--- a/superset-frontend/playwright/pages/ExplorePage.ts
+++ b/superset-frontend/playwright/pages/ExplorePage.ts
@@ -20,6 +20,8 @@
 import { Page, Locator } from '@playwright/test';
 import { TIMEOUT } from '../utils/constants';
 import { AgGrid } from '../components/core/AgGrid';
+import { Menu } from '../components/core';
+import { SaveChartModal } from '../components/modals';
 
 /**
  * Explore Page object
@@ -36,6 +38,12 @@ export class ExplorePage {
     EXPAND_DATA_PANEL: '[aria-label="Expand data panel"]',
     RESULTS_TAB: '[data-node-key="results"]',
     ACTIVE_TABPANE: '.ant-tabs-content-active',
+    SAVE_BUTTON: '[data-test="query-save-button"]',
+    METADATA_BAR: '[data-test="metadata-bar"]',
+    // Ant Design's Dropdown popupRender wraps its content in an 
OverrideProvider
+    // that renames the Menu's CSS prefix to "ant-dropdown-menu", so the chart
+    // actions menu (which carries no data-test of its own) is reliably found 
here.
+    ACTIONS_MENU_ROOT: '.ant-dropdown-menu-root',
   } as const;
 
   constructor(page: Page) {
@@ -152,4 +160,74 @@ export class ExplorePage {
       .first();
     return new AgGrid(this.page, grid);
   }
+
+  /**
+   * Gets the chart header's Save button locator.
+   */
+  getSaveButton(): Locator {
+    return this.page.locator(ExplorePage.SELECTORS.SAVE_BUTTON);
+  }
+
+  /**
+   * Clicks the Save button and returns a ready-to-use SaveChartModal.
+   */
+  async openSaveModal(): Promise<SaveChartModal> {
+    await this.getSaveButton().click();
+    const modal = new SaveChartModal(this.page);
+    await modal.waitForReady();
+    return modal;
+  }
+
+  /**
+   * Gets the chart metadata bar locator (dashboard membership, last modified, 
etc.).
+   */
+  getMetadataBar(): Locator {
+    return this.page.locator(ExplorePage.SELECTORS.METADATA_BAR);
+  }
+
+  /**
+   * Locator for the metadata bar's dashboard-membership title text, e.g.
+   * "Not added to any dashboard" or "Added to 3 dashboards". Assert with
+   * `toHaveText` so the check retries until the post-save refresh lands.
+   */
+  getDashboardsMetadataText(): Locator {
+    return this.getMetadataBar()
+      .locator('.metadata-text')
+      .filter({ hasText: /dashboard/i })
+      .first();
+  }
+
+  /**
+   * Opens the chart's "..." actions menu (the `Menu actions trigger` button)
+   * and waits for its popup to render.
+   */
+  async openActionsMenu(): Promise<void> {
+    await this.page
+      .getByRole('button', { name: 'Menu actions trigger' })
+      .click();
+    await this.page
+      .locator(ExplorePage.SELECTORS.ACTIONS_MENU_ROOT)
+      .first()
+      .waitFor({ state: 'visible', timeout: TIMEOUT.FORM_LOAD });
+  }
+
+  /**
+   * Closes the chart's actions menu (and any open submenu) via Escape.
+   */
+  async closeActionsMenu(): Promise<void> {
+    await this.page.keyboard.press('Escape');
+  }
+
+  /**
+   * Opens the chart actions menu and its "On dashboards" submenu, returning
+   * the submenu's popup locator so callers can inspect the listed dashboards.
+   */
+  async openDashboardsSubmenu(): Promise<Locator> {
+    await this.openActionsMenu();
+    const menu = new Menu(
+      this.page,
+      this.page.locator(ExplorePage.SELECTORS.ACTIONS_MENU_ROOT).first(),
+    );
+    return menu.openSubmenu('On dashboards');
+  }
 }
diff --git 
a/superset-frontend/playwright/tests/explore/cross-referenced-dashboards.spec.ts
 
b/superset-frontend/playwright/tests/explore/cross-referenced-dashboards.spec.ts
new file mode 100644
index 00000000000..1f54c18ad79
--- /dev/null
+++ 
b/superset-frontend/playwright/tests/explore/cross-referenced-dashboards.spec.ts
@@ -0,0 +1,191 @@
+/**
+ * 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.
+ */
+
+/**
+ * Migrated from the Cypress "Cross-referenced dashboards" suite in
+ * explore/chart.test.js. The original did 11 sequential UI saves; this
+ * version UI-saves to the first two dashboards (covering the singular vs.
+ * plural metadata text and the submenu listing), then attaches the rest via
+ * the API to reach the SEARCH_THRESHOLD (10) search-input behavior.
+ */
+import { testWithAssets, expect } from '../../helpers/fixtures';
+import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
+import { createTestDashboard } from '../dashboard/dashboard-test-helpers';
+import { getDatasetByName } from '../../helpers/api/dataset';
+import { extractIdFromResponse } from '../../helpers/api/assertions';
+import { waitForPut } from '../../helpers/api/intercepts';
+import { ExplorePage } from '../../pages/ExplorePage';
+import { TIMEOUT } from '../../utils/constants';
+
+const DATASET_NAME = 'birth_names';
+const TOTAL_DASHBOARDS = 11; // > SEARCH_THRESHOLD (10) to exercise the search 
input
+
+testWithAssets(
+  'chart metadata bar and "On dashboards" submenu reflect dashboard 
membership',
+  async ({ page, testAssets }, testInfo) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+
+    const dataset = await getDatasetByName(page, DATASET_NAME);
+    if (!dataset) throw new Error(`Dataset ${DATASET_NAME} not found`);
+
+    const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
+    const chartName = `xref_chart_${uniqueSuffix}`;
+
+    const chartResp = await apiPostChart(page, {
+      slice_name: chartName,
+      viz_type: 'table',
+      datasource_id: dataset.id,
+      datasource_type: 'table',
+      params: JSON.stringify({
+        datasource: `${dataset.id}__table`,
+        viz_type: 'table',
+        query_mode: 'raw',
+        all_columns: ['name', 'gender', 'num'],
+        adhoc_filters: [],
+        order_by_cols: [],
+        row_limit: 1000,
+        server_pagination: false,
+      }),
+    });
+    expect(chartResp.ok()).toBe(true);
+    const chartId = await extractIdFromResponse(chartResp);
+    testAssets.trackChart(chartId);
+
+    const explorePage = new ExplorePage(page);
+    await explorePage.goto(chartId);
+
+    // Initial state: not on any dashboard.
+    await expect(explorePage.getDashboardsMetadataText()).toHaveText(
+      'Not added to any dashboard',
+    );
+    const submenuEmpty = await explorePage.openDashboardsSubmenu();
+    await expect(submenuEmpty.getByText('None', { exact: true 
})).toBeVisible();
+    await explorePage.closeActionsMenu();
+
+    // Create dashboards 1 and 2 up front so the UI-save select can find them.
+    const dashboard1 = await createTestDashboard(page, testAssets, testInfo, {
+      prefix: `xref_dash_${uniqueSuffix}_1`,
+    });
+    const dashboard2 = await createTestDashboard(page, testAssets, testInfo, {
+      prefix: `xref_dash_${uniqueSuffix}_2`,
+    });
+
+    // UI-save to dashboard 1: verifies singular metadata text.
+    const saveModal1 = await explorePage.openSaveModal();
+    await saveModal1.selectOverwrite();
+    await saveModal1.selectDashboard(dashboard1.name);
+    const updated1 = waitForPut(page, `api/v1/chart/${chartId}`, {
+      pathMatch: true,
+    });
+    await saveModal1.clickSave();
+    expect((await updated1).ok()).toBe(true);
+    await explorePage.waitForPageLoad();
+
+    await expect(explorePage.getDashboardsMetadataText()).toHaveText(
+      'Added to 1 dashboard',
+    );
+
+    // UI-save to dashboard 2: verifies plural metadata text and that both
+    // dashboards are listed in the submenu.
+    const saveModal2 = await explorePage.openSaveModal();
+    await saveModal2.selectOverwrite();
+    await saveModal2.selectDashboard(dashboard2.name);
+    const updated2 = waitForPut(page, `api/v1/chart/${chartId}`, {
+      pathMatch: true,
+    });
+    await saveModal2.clickSave();
+    expect((await updated2).ok()).toBe(true);
+    await explorePage.waitForPageLoad();
+
+    await expect(explorePage.getDashboardsMetadataText()).toHaveText(
+      'Added to 2 dashboards',
+    );
+
+    const submenuTwo = await explorePage.openDashboardsSubmenu();
+    await expect(
+      submenuTwo.getByText(dashboard1.name, { exact: true }),
+    ).toBeVisible();
+    await expect(
+      submenuTwo.getByText(dashboard2.name, { exact: true }),
+    ).toBeVisible();
+    await explorePage.closeActionsMenu();
+
+    // Attach the remaining dashboards via the API (PUT replaces the full
+    // dashboard list, so dashboard1/dashboard2 must be included again).
+    const remainingCount = TOTAL_DASHBOARDS - 2;
+    const remainingDashboards = [];
+    for (let i = 0; i < remainingCount; i += 1) {
+      // eslint-disable-next-line no-await-in-loop
+      const dash = await createTestDashboard(page, testAssets, testInfo, {
+        prefix: `xref_dash_${uniqueSuffix}_${i + 3}`,
+      });
+      remainingDashboards.push(dash);
+    }
+    const allDashboardIds = [
+      dashboard1.id,
+      dashboard2.id,
+      ...remainingDashboards.map(d => d.id),
+    ];
+    const putResp = await apiPutChart(page, chartId, {
+      dashboards: allDashboardIds,
+    });
+    expect(putResp.ok()).toBe(true);
+
+    // Reload explore to pick up the new metadata via GET /api/v1/explore/.
+    await page.reload();
+    await explorePage.waitForPageLoad();
+
+    await expect(explorePage.getDashboardsMetadataText()).toHaveText(
+      `Added to ${TOTAL_DASHBOARDS} dashboards`,
+    );
+
+    // Search: a matching term narrows the list to the matching dashboard(s).
+    const submenuFull = await explorePage.openDashboardsSubmenu();
+    const searchInput = submenuFull.locator('input[placeholder="Search"]');
+    await searchInput.fill(dashboard1.name, { force: true });
+    await expect(
+      submenuFull.getByText(dashboard1.name, { exact: true }),
+    ).toBeVisible();
+    await expect(
+      submenuFull.getByText(dashboard2.name, { exact: true }),
+    ).not.toBeVisible();
+
+    // Gibberish search yields "No results found".
+    await searchInput.fill('zzz_no_such_dashboard_zzz', { force: true });
+    await expect(
+      submenuFull.getByText('No results found', { exact: true }),
+    ).toBeVisible();
+
+    // Clearing the search resets the full list.
+    const clearIcon = submenuFull.locator('.ant-input-clear-icon');
+    await clearIcon.click({ force: true });
+    await expect(
+      submenuFull.getByText(dashboard1.name, { exact: true }),
+    ).toBeVisible();
+
+    // The first dashboard's link opens it (in a new tab) at the right URL.
+    const [popup] = await Promise.all([
+      page.waitForEvent('popup'),
+      submenuFull.getByText(dashboard1.name, { exact: true }).click(),
+    ]);
+    await popup.waitForLoadState();
+    expect(popup.url()).toContain(`/dashboard/${dashboard1.id}`);
+    await popup.close();
+  },
+);
diff --git 
a/superset-frontend/playwright/tests/explore/explore-no-results.spec.ts 
b/superset-frontend/playwright/tests/explore/explore-no-results.spec.ts
new file mode 100644
index 00000000000..fb8572cdaaf
--- /dev/null
+++ b/superset-frontend/playwright/tests/explore/explore-no-results.spec.ts
@@ -0,0 +1,87 @@
+/**
+ * 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.
+ */
+
+/**
+ * Migrated from the Cypress `describe.skip('No Results', ...)` block in
+ * explore/chart.test.js, which was skipped due to a hardcoded datasource id
+ * (`3__table`). This version resolves the dataset by name instead.
+ */
+import { testWithAssets, expect } from '../../helpers/fixtures';
+import { apiPostChart } from '../../helpers/api/chart';
+import { getDatasetByName } from '../../helpers/api/dataset';
+import { extractIdFromResponse } from '../../helpers/api/assertions';
+import { ExplorePage } from '../../pages/ExplorePage';
+import { TIMEOUT } from '../../utils/constants';
+
+const DATASET_NAME = 'birth_names';
+
+testWithAssets(
+  'chart with a filter matching no rows shows "No results"',
+  async ({ page, testAssets }, testInfo) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+
+    const dataset = await getDatasetByName(page, DATASET_NAME);
+    if (!dataset) throw new Error(`Dataset ${DATASET_NAME} not found`);
+
+    const params = {
+      datasource: `${dataset.id}__table`,
+      viz_type: 'table',
+      query_mode: 'raw',
+      all_columns: ['name', 'gender', 'num'],
+      adhoc_filters: [
+        {
+          expressionType: 'SIMPLE',
+          subject: 'state',
+          operator: 'IN',
+          comparator: ['Fake State'],
+          clause: 'WHERE',
+        },
+      ],
+      order_by_cols: [],
+      row_limit: 1000,
+      server_pagination: false,
+    };
+
+    const resp = await apiPostChart(page, {
+      slice_name: `explore_no_results_${Date.now()}_${testInfo.parallelIndex}`,
+      viz_type: 'table',
+      datasource_id: dataset.id,
+      datasource_type: 'table',
+      params: JSON.stringify(params),
+    });
+    expect(resp.ok()).toBe(true);
+    const chartId = await extractIdFromResponse(resp);
+    testAssets.trackChart(chartId);
+
+    const explorePage = new ExplorePage(page);
+    const chartQueryFinished = page.waitForResponse(
+      response =>
+        response.request().method() === 'POST' &&
+        response.url().includes('/api/v1/chart/data'),
+      { timeout: TIMEOUT.API_RESPONSE },
+    );
+    await explorePage.goto(chartId);
+    await chartQueryFinished;
+
+    await expect(explorePage.getChartContainer()).toContainText(
+      'No results were returned for this query',
+      { timeout: TIMEOUT.CHART_RENDER },
+    );
+  },
+);
diff --git a/superset-frontend/playwright/tests/explore/explore-save.spec.ts 
b/superset-frontend/playwright/tests/explore/explore-save.spec.ts
new file mode 100644
index 00000000000..9994c9df189
--- /dev/null
+++ b/superset-frontend/playwright/tests/explore/explore-save.spec.ts
@@ -0,0 +1,179 @@
+/**
+ * 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.
+ */
+
+/**
+ * Migrated from the deleted Cypress `_skip.link.test.ts` (save-as/overwrite
+ * coverage only; the view-query and iframe cases moved to RTL elsewhere).
+ */
+import { testWithAssets, expect } from '../../helpers/fixtures';
+import {
+  apiPostChart,
+  apiGetChart,
+  getChartsByName,
+} from '../../helpers/api/chart';
+import { getDashboardsByName } from '../../helpers/api/dashboard';
+import { getDatasetByName } from '../../helpers/api/dataset';
+import { waitForPost, waitForPut } from '../../helpers/api/intercepts';
+import { extractIdFromResponse } from '../../helpers/api/assertions';
+import { ExplorePage } from '../../pages/ExplorePage';
+import { TIMEOUT } from '../../utils/constants';
+
+const DATASET_NAME = 'birth_names';
+
+async function createBaseChart(
+  page: import('@playwright/test').Page,
+  name: string,
+  datasetId: number,
+): Promise<number> {
+  const params = {
+    datasource: `${datasetId}__table`,
+    viz_type: 'table',
+    query_mode: 'raw',
+    all_columns: ['name', 'gender', 'num'],
+    adhoc_filters: [],
+    order_by_cols: [],
+    row_limit: 1000,
+    server_pagination: false,
+  };
+  const resp = await apiPostChart(page, {
+    slice_name: name,
+    viz_type: 'table',
+    datasource_id: datasetId,
+    datasource_type: 'table',
+    params: JSON.stringify(params),
+  });
+  expect(resp.ok()).toBe(true);
+  return extractIdFromResponse(resp);
+}
+
+testWithAssets(
+  'save as a new chart, then overwrite it (exactly one chart by name)',
+  async ({ page, testAssets }, testInfo) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+
+    const dataset = await getDatasetByName(page, DATASET_NAME);
+    if (!dataset) throw new Error(`Dataset ${DATASET_NAME} not found`);
+
+    const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
+    const baseChartName = `explore_save_base_${uniqueSuffix}`;
+    const newChartName = `explore_save_new_${uniqueSuffix}`;
+
+    const baseChartId = await createBaseChart(page, baseChartName, dataset.id);
+    testAssets.trackChart(baseChartId);
+
+    const explorePage = new ExplorePage(page);
+    await explorePage.goto(baseChartId);
+
+    // Save as a brand-new chart.
+    const saveModal = await explorePage.openSaveModal();
+    await saveModal.selectSaveAsNew();
+    await saveModal.fillChartName(newChartName);
+    const created = waitForPost(page, 'api/v1/chart/');
+    await saveModal.clickSave();
+    const createdResponse = await created;
+    const newChartId = await extractIdFromResponse(createdResponse);
+    testAssets.trackChart(newChartId);
+
+    await explorePage.waitForPageLoad();
+
+    // Overwrite the newly created chart.
+    const saveModal2 = await explorePage.openSaveModal();
+    await saveModal2.selectOverwrite();
+    const updated = waitForPut(page, `api/v1/chart/${newChartId}`, {
+      pathMatch: true,
+    });
+    await saveModal2.clickSave();
+    expect((await updated).ok()).toBe(true);
+
+    const { count } = await getChartsByName(page, newChartName);
+    expect(count).toBe(1);
+  },
+);
+
+testWithAssets(
+  'save as + add to new dashboard, then overwrite selecting the existing 
dashboard',
+  async ({ page, testAssets }, testInfo) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+
+    const dataset = await getDatasetByName(page, DATASET_NAME);
+    if (!dataset) throw new Error(`Dataset ${DATASET_NAME} not found`);
+
+    const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
+    const baseChartName = `explore_save_dash_base_${uniqueSuffix}`;
+    const newChartName = `explore_save_dash_new_${uniqueSuffix}`;
+    const dashboardTitle = `explore_save_dash_${uniqueSuffix}`;
+
+    const baseChartId = await createBaseChart(page, baseChartName, dataset.id);
+    testAssets.trackChart(baseChartId);
+
+    const explorePage = new ExplorePage(page);
+    await explorePage.goto(baseChartId);
+
+    // Save as a new chart, adding it to a brand-new dashboard (creatable 
select).
+    const saveModal = await explorePage.openSaveModal();
+    await saveModal.selectSaveAsNew();
+    await saveModal.fillChartName(newChartName);
+    await saveModal.selectDashboard(dashboardTitle);
+    const dashboardCreated = waitForPost(page, 'api/v1/dashboard/', {
+      pathMatch: true,
+    });
+    const created = waitForPost(page, 'api/v1/chart/');
+    await saveModal.clickSave();
+    // Track the dashboard before the chart POST so a later failure still
+    // cleans it up.
+    const dashboardId = await extractIdFromResponse(await dashboardCreated);
+    testAssets.trackDashboard(dashboardId);
+    const createdResponse = await created;
+    const newChartId = await extractIdFromResponse(createdResponse);
+    testAssets.trackChart(newChartId);
+
+    const dashboardsAfterCreate = await getDashboardsByName(
+      page,
+      dashboardTitle,
+    );
+    expect(dashboardsAfterCreate.count).toBe(1);
+    expect(dashboardsAfterCreate.result[0].id).toBe(dashboardId);
+
+    await explorePage.waitForPageLoad();
+
+    // Overwrite, selecting the now-existing dashboard by the same title.
+    const saveModal2 = await explorePage.openSaveModal();
+    await saveModal2.selectOverwrite();
+    await saveModal2.selectDashboard(dashboardTitle);
+    const updated = waitForPut(page, `api/v1/chart/${newChartId}`, {
+      pathMatch: true,
+    });
+    await saveModal2.clickSave();
+    expect((await updated).ok()).toBe(true);
+
+    const chartsResult = await getChartsByName(page, newChartName);
+    expect(chartsResult.count).toBe(1);
+
+    const dashboardsResult = await getDashboardsByName(page, dashboardTitle);
+    expect(dashboardsResult.count).toBe(1);
+
+    // Confirm the chart is actually associated with the dashboard.
+    const chartDetail = await apiGetChart(page, newChartId);
+    const chartBody = await chartDetail.json();
+    const dashboardIds: number[] = (chartBody.result?.dashboards ?? []).map(
+      (d: { id: number }) => d.id,
+    );
+    expect(dashboardIds).toContain(dashboardsResult.result[0].id);
+  },
+);

Reply via email to