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;