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

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 52552c8278e test(dashboard): migrate dashboard force-refresh control 
to Playwright (#41433)
52552c8278e is described below

commit 52552c8278ecbced4149dde5b90f4544e5eb8e66
Author: Joe Li <[email protected]>
AuthorDate: Fri Jul 24 13:32:09 2026 -0700

    test(dashboard): migrate dashboard force-refresh control to Playwright 
(#41433)
    
    Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
 .../playwright/components/core/Menu.ts             |  20 +++
 .../playwright/pages/DashboardPage.ts              |  22 ++-
 .../tests/dashboard/dashboard-controls.spec.ts     | 152 +++++++++++++++++++++
 .../tests/dashboard/dashboard-load.spec.ts         | 113 +++++----------
 .../tests/dashboard/dashboard-test-helpers.ts      | 121 +++++++++++++++-
 .../tests/dashboard/gauge-interval-colors.spec.ts  |   3 +-
 superset-frontend/playwright/utils/constants.ts    |  11 +-
 7 files changed, 355 insertions(+), 87 deletions(-)

diff --git a/superset-frontend/playwright/components/core/Menu.ts 
b/superset-frontend/playwright/components/core/Menu.ts
index e312f0283c5..a321a786069 100644
--- a/superset-frontend/playwright/components/core/Menu.ts
+++ b/superset-frontend/playwright/components/core/Menu.ts
@@ -63,6 +63,26 @@ export class Menu {
     }
   }
 
+  /**
+   * Selects a top-level menu item by its accessible name.
+   *
+   * Ant Design menu items render as `div[role="menuitem"]` labelled by their
+   * text, so the accessible name is the stable handle — most items carry no
+   * `data-test`. Use this for flat items; use {@link selectSubmenuItem} for
+   * items nested under a submenu.
+   *
+   * @param itemText - The accessible name of the item (e.g., "Refresh 
dashboard")
+   * @param options - Optional timeout settings
+   */
+  async selectItem(
+    itemText: string,
+    options?: { timeout?: number },
+  ): Promise<void> {
+    await this.locator
+      .getByRole('menuitem', { name: itemText, exact: true })
+      .click({ timeout: options?.timeout ?? TIMEOUT.FORM_LOAD });
+  }
+
   /**
    * Opens a submenu and selects an item within it.
    * Uses hover as primary approach, falls back to keyboard then dispatchEvent.
diff --git a/superset-frontend/playwright/pages/DashboardPage.ts 
b/superset-frontend/playwright/pages/DashboardPage.ts
index 7c3e91a17b8..0c58525ae25 100644
--- a/superset-frontend/playwright/pages/DashboardPage.ts
+++ b/superset-frontend/playwright/pages/DashboardPage.ts
@@ -167,6 +167,23 @@ export class DashboardPage {
     );
   }
 
+  /**
+   * The dashboard header actions dropdown menu. Call after
+   * {@link openHeaderActionsMenu}, which is what makes the menu visible.
+   */
+  private headerActionsMenu(): Menu {
+    return new Menu(this.page, DashboardPage.SELECTORS.HEADER_ACTIONS_MENU);
+  }
+
+  /**
+   * Trigger a dashboard-level force refresh via the header actions menu.
+   * Re-runs every chart's query with `force=true`, bypassing the cache.
+   */
+  async forceRefresh(): Promise<void> {
+    await this.openHeaderActionsMenu();
+    await this.headerActionsMenu().selectItem('Refresh dashboard');
+  }
+
   /**
    * Selects an option from the Download submenu.
    * Opens the header actions menu, navigates to Download submenu,
@@ -177,10 +194,7 @@ export class DashboardPage {
   async selectDownloadOption(optionText: string): Promise<Download> {
     await this.openHeaderActionsMenu();
 
-    const menu = new Menu(
-      this.page,
-      DashboardPage.SELECTORS.HEADER_ACTIONS_MENU,
-    );
+    const menu = this.headerActionsMenu();
     const downloadPromise = this.page.waitForEvent('download');
     await menu.selectSubmenuItem('Download', optionText);
     return downloadPromise;
diff --git 
a/superset-frontend/playwright/tests/dashboard/dashboard-controls.spec.ts 
b/superset-frontend/playwright/tests/dashboard/dashboard-controls.spec.ts
new file mode 100644
index 00000000000..82d55058547
--- /dev/null
+++ b/superset-frontend/playwright/tests/dashboard/dashboard-controls.spec.ts
@@ -0,0 +1,152 @@
+/**
+ * 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.
+ */
+
+/**
+ * E2E migration of the Cypress "Dashboard top-level controls" suite
+ * (dashboard/controls.test.ts).
+ *
+ * The genuine end-to-end behaviour is the dashboard-level force refresh: every
+ * chart on the dashboard must re-query the backend with `force=true`. This can
+ * only be verified against a real backend, so it is migrated here.
+ *
+ * Scope note on cache bypass: the original Cypress test ran against the
+ * pre-seeded World Health dashboard and asserted `is_cached === false` on the
+ * refreshed responses, proving force-refresh busted a populated cache. This
+ * migration asserts the observable network contract instead — every chart
+ * re-queries with `force=true` and each response is accepted (200) — and does
+ * not assert `is_cached`. Tying the assertion to cache state would mean
+ * reasoning about whatever the initial dashboard load left in the DATA_CACHE 
and
+ * racing its 30s TTL, reintroducing the flakiness this migration exists to
+ * remove. `force=true` is itself the server-side instruction to bypass the
+ * cache, so asserting it fires on every distinct chart — and each request is
+ * accepted (200) — is a direct, deterministic check of the force-refresh
+ * behaviour.
+ *
+ * The other case ("should allow chart level refresh") only asserted the menu
+ * item's `ant-dropdown-menu-item-disabled` class — a DOM/state assertion with 
no
+ * backend round-trip, and one the original itself flagged as flaky. It belongs
+ * in component/RTL coverage and is intentionally not migrated.
+ *
+ * CI green => force refresh re-queries every distinct chart with force=true 
and
+ *             each request returns 200.
+ * CI red   => force refresh did not re-query every chart, or a request failed.
+ */
+import { testWithAssets, expect } from '../../helpers/fixtures';
+import { TIMEOUT } from '../../utils/constants';
+import { DashboardPage } from '../../pages/DashboardPage';
+import {
+  createDashboardWithCharts,
+  sliceIdFromChartDataUrl,
+} from './dashboard-test-helpers';
+
+testWithAssets(
+  'dashboard-level force refresh re-queries every chart with force=true',
+  async ({ page, testAssets }) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+
+    const { dashboardId, charts } = await createDashboardWithCharts(
+      page,
+      testAssets,
+      testWithAssets.info(),
+      {
+        datasetName: 'birth_names',
+        chartNamePrefix: 'controls',
+        dashboardTitlePrefix: 'controls_force_refresh',
+        chartSpecs: [
+          { viz_type: 'big_number_total', params: { metric: 'count' } },
+          {
+            viz_type: 'table',
+            params: {
+              query_mode: 'aggregate',
+              groupby: ['name'],
+              metrics: ['count'],
+              row_limit: 100,
+            },
+          },
+        ],
+      },
+    );
+    const chartIds = charts.map(chart => chart.id);
+
+    const dashboard = new DashboardPage(page);
+    await dashboard.gotoById(dashboardId);
+    await dashboard.waitForLoad();
+    await dashboard.waitForChartsToLoad();
+
+    // Capture the force-refresh chart-data round-trips, keyed by the chart 
each
+    // one queried for. A `page.on('response')` listener rather than the
+    // `waitForPost` helper: those resolve on a single response, and this needs
+    // to collect every chart's request and correlate them by slice id. Set up
+    // after the initial load so only the forced requests are recorded. 
Statuses
+    // accumulate per chart rather than being overwritten, so a failure 
followed
+    // by a successful retry still surfaces, provided both responses land 
before
+    // the assertions read the map. That is a defence-in-depth choice more 
than a
+    // guarantee: the client retries only 502/503/504, so an application-level
+    // failure is never retried away to begin with.
+    const forcedStatusesBySliceId = new Map<number, number[]>();
+    page.on('response', response => {
+      const request = response.request();
+      if (request.method() !== 'POST') {
+        return;
+      }
+      const url = new URL(response.url());
+      // Match `force` exactly as a query param — a substring check would also
+      // accept `enforce=true` or `force=trueish`.
+      if (
+        !url.pathname.includes('/api/v1/chart/data') ||
+        url.searchParams.get('force') !== 'true'
+      ) {
+        return;
+      }
+      const sliceId = sliceIdFromChartDataUrl(response.url());
+      if (sliceId !== undefined) {
+        const statuses = forcedStatusesBySliceId.get(sliceId) ?? [];
+        statuses.push(response.status());
+        forcedStatusesBySliceId.set(sliceId, statuses);
+      }
+    });
+
+    await dashboard.forceRefresh();
+
+    // Every chart — identified by slice_id, not merely by request count — must
+    // fire its own forced re-query, and no foreign chart may: polling the
+    // distinct set (rather than the raw request count) rejects a regression 
that
+    // refreshes one chart twice while skipping another. On timeout the diff
+    // names the slice ids that never arrived.
+    const byId = (a: number, b: number) => a - b;
+    await expect
+      .poll(() => [...forcedStatusesBySliceId.keys()].sort(byId), {
+        message:
+          'every dashboard chart should issue its own forced 
/api/v1/chart/data request',
+        timeout: TIMEOUT.API_RESPONSE,
+      })
+      .toEqual([...chartIds].sort(byId));
+
+    // Every forced request recorded for a chart was accepted — all attempts, 
not
+    // just the last. The poll above already established that the recorded 
slice
+    // ids are exactly the dashboard's charts, so iterating the map needs no
+    // presence or emptiness guard.
+    for (const [sliceId, statuses] of forcedStatusesBySliceId) {
+      expect(
+        statuses.every(status => status === 200),
+        `chart ${sliceId}'s forced /api/v1/chart/data responses should all be 
200, got [${statuses}]`,
+      ).toBe(true);
+    }
+  },
+);
diff --git 
a/superset-frontend/playwright/tests/dashboard/dashboard-load.spec.ts 
b/superset-frontend/playwright/tests/dashboard/dashboard-load.spec.ts
index bedb8d1ddcd..8eb8cfd4e68 100644
--- a/superset-frontend/playwright/tests/dashboard/dashboard-load.spec.ts
+++ b/superset-frontend/playwright/tests/dashboard/dashboard-load.spec.ts
@@ -35,18 +35,14 @@
  */
 import type { Locator } from '@playwright/test';
 import { testWithAssets, expect } from '../../helpers/fixtures';
-import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
-import {
-  apiPostDashboard,
-  buildSingleRowDashboardLayout,
-  type DashboardLayoutChart,
-} from '../../helpers/api/dashboard';
-import { getDatasetByName } from '../../helpers/api/dataset';
-import { extractIdFromResponse } from '../../helpers/api/assertions';
+import { type DashboardLayoutChart } from '../../helpers/api/dashboard';
 import { TIMEOUT } from '../../utils/constants';
 import { DashboardPage } from '../../pages/DashboardPage';
+import {
+  createDashboardWithCharts,
+  sliceIdFromChartDataUrl,
+} from './dashboard-test-helpers';
 
-const DATASET_NAME = 'birth_names';
 const ECHARTS_SERIES_COLOR: [number, number, number] = [31, 168, 201];
 
 type ChartOutput = 'big-number' | 'table' | 'echarts';
@@ -85,22 +81,22 @@ async function expectChartOutput(
     const value = chart.locator(
       '.superset-legacy-chart-big-number .header-line',
     );
-    await expect(value).toBeVisible();
+    await expect(value).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
     await expect(value).toHaveText(/\d/);
     return;
   }
   if (output === 'table') {
     await expect(
       chart.locator('table tbody tr:not(:has(.dt-no-results))').first(),
-    ).toBeVisible();
+    ).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
     return;
   }
 
   const canvas = chart.locator('canvas').first();
-  await expect(canvas).toBeVisible();
+  await expect(canvas).toBeVisible({ timeout: TIMEOUT.CHART_RENDER });
   await expect
     .poll(() => canvasColorPixelCount(canvas, ECHARTS_SERIES_COLOR), {
-      timeout: TIMEOUT.API_RESPONSE * 2,
+      timeout: TIMEOUT.CHART_RENDER,
       message: 'ECharts canvas should paint the configured data series',
     })
     .toBeGreaterThan(20);
@@ -109,17 +105,14 @@ async function expectChartOutput(
 testWithAssets(
   'dashboard loads and every chart renders via real queries',
   async ({ page, testAssets }) => {
-    // Building + loading a multi-chart dashboard chains several slow queries.
-    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
-
-    const dataset = await getDatasetByName(page, DATASET_NAME);
-    if (!dataset) {
-      throw new Error(`Dataset ${DATASET_NAME} not found`);
-    }
-    const datasetId = dataset.id;
-    const datasource = `${datasetId}__table`;
-
-    // A spread of viz types that all render cleanly from the birth_names 
dataset.
+    // Building + loading a multi-chart dashboard chains several slow queries:
+    // API setup per chart, then an uncached render for each. A loaded runner 
has
+    // been seen spending ~45s on that chain, which leaves the standard slow
+    // budget no room to actually spend TIMEOUT.CHART_RENDER.
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST * 2);
+
+    // A spread of viz types that all render cleanly from the birth_names 
dataset,
+    // each paired with the terminal output it should paint.
     const chartSpecs: {
       viz_type: string;
       output: ChartOutput;
@@ -128,14 +121,12 @@ testWithAssets(
       {
         viz_type: 'big_number_total',
         output: 'big-number',
-        params: { datasource, viz_type: 'big_number_total', metric: 'count' },
+        params: { metric: 'count' },
       },
       {
         viz_type: 'table',
         output: 'table',
         params: {
-          datasource,
-          viz_type: 'table',
           query_mode: 'aggregate',
           groupby: ['name'],
           metrics: ['count'],
@@ -146,8 +137,6 @@ testWithAssets(
         viz_type: 'echarts_timeseries_line',
         output: 'echarts',
         params: {
-          datasource,
-          viz_type: 'echarts_timeseries_line',
           x_axis: 'ds',
           xAxisForceCategorical: true,
           time_grain_sqla: 'P1Y',
@@ -160,49 +149,23 @@ testWithAssets(
       },
     ];
 
-    // Parallel-safe suffix so chart/dashboard names never collide across 
workers.
-    const uniqueSuffix = 
`${Date.now()}_${testWithAssets.info().parallelIndex}`;
-
-    // Create each chart via the API.
-    const charts: CreatedChart[] = [];
-    for (const spec of chartSpecs) {
-      const sliceName = `load_smoke_${spec.viz_type}_${uniqueSuffix}`;
-      const resp = await apiPostChart(page, {
-        slice_name: sliceName,
-        viz_type: spec.viz_type,
-        datasource_id: datasetId,
-        datasource_type: 'table',
-        params: JSON.stringify(spec.params),
+    const { dashboardId, charts: createdCharts } =
+      await createDashboardWithCharts(page, testAssets, testWithAssets.info(), 
{
+        datasetName: 'birth_names',
+        chartNamePrefix: 'load_smoke',
+        dashboardTitlePrefix: 'load_smoke',
+        chartSpecs,
       });
-      expect(resp.ok()).toBe(true);
-      const chartId = await extractIdFromResponse(resp);
-      testAssets.trackChart(chartId);
-      charts.push({ id: chartId, sliceName, output: spec.output });
-    }
+    // Pair each created chart back to the output it should paint (same order).
+    const charts: CreatedChart[] = createdCharts.map((chart, index) => ({
+      ...chart,
+      output: chartSpecs[index].output,
+    }));
     const chartIds = charts.map(chart => chart.id);
 
-    // Lay all charts out in a single row.
-    const positionJson = buildSingleRowDashboardLayout(charts);
-
-    const dashResp = await apiPostDashboard(page, {
-      dashboard_title: `load_smoke_${uniqueSuffix}`,
-      published: true,
-      position_json: JSON.stringify(positionJson),
-    });
-    expect(dashResp.ok()).toBe(true);
-    const dashboardId = await extractIdFromResponse(dashResp);
-    testAssets.trackDashboard(dashboardId);
-
-    // Associate every chart with the dashboard so they actually render.
-    for (const chartId of chartIds) {
-      await apiPutChart(page, chartId, { dashboards: [dashboardId] });
-    }
-
     // Record the real chart-data round-trips the dashboard makes on load,
-    // keyed by the chart each one queried for. The chart-data POST carries its
-    // slice id in the encoded `form_data={"slice_id":<id>}` query param (see
-    // chartAction.ts), so parsing it lets us prove every chart queried — not
-    // just that some chart did.
+    // keyed by the chart each one queried for, so we can prove every chart
+    // queried — not just that some chart did.
     const chartDataStatusBySliceId = new Map<number, number>();
     page.on('response', response => {
       const request = response.request();
@@ -212,17 +175,9 @@ testWithAssets(
       ) {
         return;
       }
-      const formData = new URL(response.url()).searchParams.get('form_data');
-      if (!formData) {
-        return;
-      }
-      try {
-        const sliceId = JSON.parse(formData).slice_id;
-        if (typeof sliceId === 'number') {
-          chartDataStatusBySliceId.set(sliceId, response.status());
-        }
-      } catch {
-        // Not a slice-id form_data payload; ignore.
+      const sliceId = sliceIdFromChartDataUrl(response.url());
+      if (sliceId !== undefined) {
+        chartDataStatusBySliceId.set(sliceId, response.status());
       }
     });
 
diff --git 
a/superset-frontend/playwright/tests/dashboard/dashboard-test-helpers.ts 
b/superset-frontend/playwright/tests/dashboard/dashboard-test-helpers.ts
index 9f45661342e..30755c8e176 100644
--- a/superset-frontend/playwright/tests/dashboard/dashboard-test-helpers.ts
+++ b/superset-frontend/playwright/tests/dashboard/dashboard-test-helpers.ts
@@ -18,8 +18,41 @@
  */
 
 import type { Page, TestInfo } from '@playwright/test';
-import type { TestAssets } from '../../helpers/fixtures';
-import { apiPostDashboard } from '../../helpers/api/dashboard';
+import { expect, type TestAssets } from '../../helpers/fixtures';
+import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
+import {
+  apiPostDashboard,
+  buildSingleRowDashboardLayout,
+  type DashboardLayoutChart,
+} from '../../helpers/api/dashboard';
+import { getDatasetByName } from '../../helpers/api/dataset';
+import { extractIdFromResponse } from '../../helpers/api/assertions';
+
+/**
+ * Extracts the chart id that a `/api/v1/chart/data` request was issued for.
+ *
+ * The chart-data POST carries its slice id in the encoded
+ * `form_data={"slice_id":<id>}` query param (see `chartAction.ts`). Parsing it
+ * lets a test tie each request back to a specific chart and assert that every
+ * chart queried — rather than only counting requests, which cannot distinguish
+ * "all charts queried once" from "one chart queried twice, another skipped".
+ *
+ * @param url - The chart-data request or response URL
+ * @returns The slice id, or undefined if the URL carries no parsable one
+ */
+export function sliceIdFromChartDataUrl(url: string): number | undefined {
+  const formData = new URL(url).searchParams.get('form_data');
+  if (!formData) {
+    return undefined;
+  }
+  try {
+    const sliceId = JSON.parse(formData).slice_id;
+    return typeof sliceId === 'number' ? sliceId : undefined;
+  } catch {
+    // Not a slice-id form_data payload.
+    return undefined;
+  }
+}
 
 interface TestDashboardResult {
   id: number;
@@ -72,3 +105,87 @@ export async function createTestDashboard(
 
   return { id, name };
 }
+
+export interface DashboardChartSpec {
+  /** Sent as the chart's top-level `viz_type` and injected into its params. */
+  viz_type: string;
+  /**
+   * Chart params minus `datasource` and `viz_type` — the helper injects both
+   * (the datasource is resolved from the dataset, so callers never thread the
+   * dataset id through their spec).
+   */
+  params: Record<string, unknown>;
+}
+
+interface CreateDashboardWithChartsOptions {
+  /** Example dataset the charts query (e.g. 'birth_names'). */
+  datasetName: string;
+  /** Chart slice-name prefix: `${chartNamePrefix}_${viz_type}_${suffix}`. */
+  chartNamePrefix: string;
+  /** Dashboard title prefix: `${dashboardTitlePrefix}_${suffix}`. */
+  dashboardTitlePrefix: string;
+  chartSpecs: DashboardChartSpec[];
+}
+
+/**
+ * Builds a published dashboard via the API: creates each chart, lays them out 
in
+ * a single row, and associates them so they render. Every created chart and 
the
+ * dashboard are registered for fixture cleanup. Charts are returned in the 
same
+ * order as `chartSpecs`, so callers can pair them back to per-spec metadata by
+ * index.
+ */
+export async function createDashboardWithCharts(
+  page: Page,
+  testAssets: TestAssets,
+  testInfo: TestInfo,
+  options: CreateDashboardWithChartsOptions,
+): Promise<{ dashboardId: number; charts: DashboardLayoutChart[] }> {
+  const dataset = await getDatasetByName(page, options.datasetName);
+  if (!dataset) {
+    throw new Error(`Dataset ${options.datasetName} not found`);
+  }
+  const datasource = `${dataset.id}__table`;
+
+  // Parallel-safe suffix so chart/dashboard names never collide across 
workers.
+  const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
+
+  const charts: DashboardLayoutChart[] = [];
+  for (const spec of options.chartSpecs) {
+    const sliceName = 
`${options.chartNamePrefix}_${spec.viz_type}_${uniqueSuffix}`;
+    const resp = await apiPostChart(page, {
+      slice_name: sliceName,
+      viz_type: spec.viz_type,
+      datasource_id: dataset.id,
+      datasource_type: 'table',
+      params: JSON.stringify({
+        // Caller params first so the helper-owned datasource/viz_type always 
win
+        // and a stray key in a spec cannot repoint the chart at another 
dataset.
+        ...spec.params,
+        datasource,
+        viz_type: spec.viz_type,
+      }),
+    });
+    expect(resp.ok()).toBe(true);
+    const chartId = await extractIdFromResponse(resp);
+    testAssets.trackChart(chartId);
+    charts.push({ id: chartId, sliceName });
+  }
+
+  // Lay all charts out in a single row.
+  const positionJson = buildSingleRowDashboardLayout(charts);
+  const dashResp = await apiPostDashboard(page, {
+    dashboard_title: `${options.dashboardTitlePrefix}_${uniqueSuffix}`,
+    published: true,
+    position_json: JSON.stringify(positionJson),
+  });
+  expect(dashResp.ok()).toBe(true);
+  const dashboardId = await extractIdFromResponse(dashResp);
+  testAssets.trackDashboard(dashboardId);
+
+  // Associate every chart with the dashboard so they actually render.
+  for (const chart of charts) {
+    await apiPutChart(page, chart.id, { dashboards: [dashboardId] });
+  }
+
+  return { dashboardId, charts };
+}
diff --git 
a/superset-frontend/playwright/tests/dashboard/gauge-interval-colors.spec.ts 
b/superset-frontend/playwright/tests/dashboard/gauge-interval-colors.spec.ts
index 2ccf67d62e1..60edba569f0 100644
--- a/superset-frontend/playwright/tests/dashboard/gauge-interval-colors.spec.ts
+++ b/superset-frontend/playwright/tests/dashboard/gauge-interval-colors.spec.ts
@@ -41,6 +41,7 @@ import {
   buildSingleRowDashboardLayout,
 } from '../../helpers/api/dashboard';
 import { getDatasetByName } from '../../helpers/api/dataset';
+import { TIMEOUT } from '../../utils/constants';
 import { DashboardPage } from '../../pages/DashboardPage';
 
 const DATASET_NAME = 'birth_names';
@@ -118,7 +119,7 @@ testWithAssets(
     await dashboardPage.waitForChartsToLoad();
 
     const canvas = page.locator('[data-test="chart-container"] 
canvas').first();
-    await canvas.waitFor({ state: 'visible', timeout: 30_000 });
+    await canvas.waitFor({ state: 'visible', timeout: TIMEOUT.CHART_RENDER });
 
     // Read the configured interval colors back from the rendered canvas.
     // Poll because the gauge paints shortly after the chart container appears.
diff --git a/superset-frontend/playwright/utils/constants.ts 
b/superset-frontend/playwright/utils/constants.ts
index db9ebd1c18b..bdaff0037d0 100644
--- a/superset-frontend/playwright/utils/constants.ts
+++ b/superset-frontend/playwright/utils/constants.ts
@@ -72,6 +72,15 @@ export const TIMEOUT = {
    */
   QUERY_EXECUTION: 30000, // 30s for SQL queries
 
+  /**
+   * A chart going from mounted to painted with real data.
+   * Navigating to a dashboard only waits for its header, so the first chart
+   * asserted absorbs the whole uncached query round-trip — more than the 
global
+   * expect timeout allows on a loaded runner. Charts query in parallel, so a
+   * dashboard full of them pays this roughly once.
+   */
+  CHART_RENDER: 30000, // 30s for a chart to query and paint
+
   /**
    * Extended test timeout for multi-step tests (page load + query execution + 
assertions).
    * Use with test.setTimeout() when the default 30s test timeout is 
insufficient.
@@ -89,5 +98,5 @@ export const EMBEDDED = {
   /** Timeout for dashboard content to render inside the iframe */
   DASHBOARD_RENDER: 30000, // 30s
   /** Timeout for individual chart cells to finish rendering */
-  CHART_RENDER: 30000, // 30s
+  CHART_RENDER: TIMEOUT.CHART_RENDER,
 } as const;

Reply via email to