Compare commits

...

7 Commits

Author SHA1 Message Date
Joe Li
d29f2491f8 test(dashboard): stabilize dashboard load smoke test 2026-07-15 17:03:56 -07:00
Joe Li
a346867e77 Merge branch 'master' into dashboard-pw-load-modes 2026-07-15 13:19:56 -07:00
Joe Li
17144d1cda test(dashboard): resolve chart render state from terminal markup
Address review feedback on the dashboard load spec:

- Replace the #chart-id-<id> visibility wait, which false-passes when a
  plugin fails to load (SuperChartCore emits the id container up front and
  mounts the viz lazily inside it) and false-fails valid no-results charts
  (SuperChart renders EmptyState as a fragment that never receives the id).
  DashboardPage.getChartRenderState now resolves rendered/no-results/failed
  from each outcome's actual markup, ruling out the failed and empty markers
  before testing #chart-id-<id> for content — EmptyState's svg is inlined by
  @svgr/webpack, so an empty chart would otherwise read as rendered. The
  check is viz-type agnostic: big_number_total paints text, not a canvas.

- Accept 200 or 202 for /api/v1/chart/data: with GLOBAL_ASYNC_QUERIES a
  cold-cache query legitimately returns 202 and delivers its result out of
  band, so the render assertion is what proves the data arrived.

- Keep the single-row position_json builder local to the spec and make it
  throw on grid overflow instead of hoisting a parallel layout helper;
  restores dashboard-test-helpers.ts to its prior state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:28:35 -07:00
Joe Li
e9e489b5f2 test(dashboard): strengthen chart render + per-chart query assertions in load spec
Address round-2 review feedback on the dashboard-load Playwright migration:

- Render proof: wait on each expected `#chart-id-<id>` (the ChartRenderer
  render marker) instead of snapshotting holder count after the first holder
  attaches. A chart that never renders now times out and fails the test
  rather than passing on a partial count.
- Query proof: collect chart-data POSTs keyed by the slice_id encoded in the
  `form_data` query param, then assert every expected chart issued a 200 —
  not just that one chart-data request succeeded.
- Reuse `extractIdFromResponse` for both chart and dashboard creation instead
  of re-implementing the `result?.id ?? id` normalization twice.
- Extract the ROOT/GRID/ROW/CHART position_json scaffold into a typed
  `buildDashboardPositionJson` helper in dashboard-test-helpers.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:16:57 +00:00
Joe Li
cbe73f0b42 test(dashboard): address review feedback on load spec
- Use typed apiPostChart/apiPutChart instead of raw apiPost/apiPut
- Collapse the special-cased chart-association loop into one loop
- Guard chart id (result?.id ?? id) and the data-test-chart-id
  attribute against undefined for a clear failure instead of a
  #chart-id-undefined timeout
- Match meta.sliceName to each chart's real slice_name
- Parallel-safe chart/dashboard names via Date.now()_parallelIndex

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:15:22 +00:00
Joe Li
a5eb02bb67 test(dashboard): use typed getDatasetByName helper in load spec
Replace the local findDatasetIdByName(page: any) with the existing typed
getDatasetByName(page: Page) helper, which routes through the retry-wrapped
apiGet path and avoids a new any. Addresses review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:15:22 +00:00
Joe Li
0f052f3a03 test(dashboard): migrate dashboard load smoke test to Playwright
Migrates the genuine end-to-end case from the legacy Cypress "Dashboard
load" suite. Builds a multi-chart dashboard via the API, loads it, and
asserts every chart renders by issuing real /api/v1/chart/data queries
that all return 200.

The remaining legacy cases (edit/standalone URL-param rendering,
send-log-data) only assert DOM/URL state with no backend round-trip and
are better served by component/RTL coverage, so they are not migrated here.

Adds DashboardPage.waitForAllChartsRendered(), which derives the chart set
from the dashboard and waits on each chart's render marker (the same signal
the legacy Cypress waitForChartLoad relied on).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:15:22 +00:00
8 changed files with 386 additions and 150 deletions

View File

@@ -19,6 +19,19 @@
import { Page, APIResponse } from '@playwright/test';
import rison from 'rison';
import getEmptyLayout from '../../../src/dashboard/util/getEmptyLayout';
import {
BACKGROUND_TRANSPARENT,
DASHBOARD_GRID_ID,
DASHBOARD_ROOT_ID,
GRID_COLUMN_COUNT,
GRID_DEFAULT_CHART_WIDTH,
} from '../../../src/dashboard/util/constants';
import {
CHART_TYPE,
ROW_TYPE,
} from '../../../src/dashboard/util/componentTypes';
import type { LayoutItem } from '../../../src/dashboard/types';
import {
apiGet,
apiPost,
@@ -47,6 +60,78 @@ export interface DashboardCreatePayload {
theme_id?: number;
}
export interface DashboardLayoutChart {
id: number;
sliceName: string;
width?: number;
height?: number;
}
type DashboardPositionItem = Omit<LayoutItem, 'meta'> & {
meta?: LayoutItem['meta'];
};
export type DashboardPositionJson = Record<
string,
DashboardPositionItem | string
>;
const DASHBOARD_ROW_ID = 'ROW-1';
const DEFAULT_CHART_HEIGHT = 50;
/**
* Build a v2 dashboard layout with every chart in one row.
*/
export function buildSingleRowDashboardLayout(
charts: readonly DashboardLayoutChart[],
): DashboardPositionJson {
const totalWidth = charts.reduce(
(sum, chart) => sum + (chart.width ?? GRID_DEFAULT_CHART_WIDTH),
0,
);
if (totalWidth > GRID_COLUMN_COUNT) {
throw new Error(
`Chart widths total ${totalWidth} columns, exceeding the ` +
`${GRID_COLUMN_COUNT}-column dashboard grid`,
);
}
const emptyLayout = getEmptyLayout();
const chartKeys = charts.map(chart => `CHART-${chart.id}`);
const positionJson: DashboardPositionJson = {
...emptyLayout,
[DASHBOARD_GRID_ID]: {
...emptyLayout[DASHBOARD_GRID_ID],
children: [DASHBOARD_ROW_ID],
},
[DASHBOARD_ROW_ID]: {
type: ROW_TYPE,
id: DASHBOARD_ROW_ID,
children: chartKeys,
parents: [DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID],
meta: { background: BACKGROUND_TRANSPARENT },
},
};
charts.forEach(chart => {
const chartKey = `CHART-${chart.id}`;
positionJson[chartKey] = {
type: CHART_TYPE,
id: chartKey,
children: [],
parents: [DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID, DASHBOARD_ROW_ID],
meta: {
chartId: chart.id,
width: chart.width ?? GRID_DEFAULT_CHART_WIDTH,
height: chart.height ?? DEFAULT_CHART_HEIGHT,
sliceName: chart.sliceName,
},
};
});
return positionJson;
}
/**
* POST request to create a dashboard
* @param page - Playwright page instance (provides authentication context)

View File

@@ -69,6 +69,15 @@ export class DashboardPage {
});
}
/**
* Get a chart grid component by its chart ID.
*/
getChart(chartId: number): Locator {
return this.page.locator(
`[data-test="chart-grid-component"][data-test-chart-id="${chartId}"]`,
);
}
/**
* Wait for all charts on the dashboard to finish loading.
* Waits until no loading indicators are visible on the page.

View File

@@ -19,7 +19,10 @@
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPost, apiPut } from '../../helpers/api/requests';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import {
apiPostDashboard,
buildSingleRowDashboardLayout,
} from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import { DashboardPage } from '../../pages/DashboardPage';
import { TIMEOUT } from '../../utils/constants';
@@ -61,36 +64,14 @@ testWithAssets(
// Create dashboard with chart in position_json and a native filter in json_metadata
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
const chartLayoutKey = `CHART-${chartId}`;
const positionJson = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-1'],
parents: ['ROOT_ID'],
const positionJson = buildSingleRowDashboardLayout([
{
id: chartId,
sliceName: 'clear_all_repro',
width: 6,
height: 50,
},
'ROW-1': {
type: 'ROW',
id: 'ROW-1',
children: [chartLayoutKey],
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
[chartLayoutKey]: {
type: 'CHART',
id: chartLayoutKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
meta: {
chartId,
width: 6,
height: 50,
sliceName: 'clear_all_repro',
},
},
};
]);
const jsonMetadata = {
native_filter_configuration: [

View File

@@ -0,0 +1,244 @@
/**
* 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 load" suite (dashboard/load.test.ts).
*
* Only the "should load dashboard" case is a genuine end-to-end test: it loads a
* multi-chart dashboard and proves every chart renders by issuing real backend
* queries. The remaining legacy cases (edit/standalone URL-param rendering,
* send-log-data) only assert DOM/URL state with no backend round-trip and belong
* in component/RTL coverage instead.
*
* The dashboard and charts are built via the API and cleaned up by the fixture;
* the chart queries use the repository's read-only birth_names example dataset.
*
* CI green => the dashboard route mounts, every chart POSTs /api/v1/chart/data
* successfully, and each chart paints its expected output.
* CI red => the dashboard failed to load or a chart never rendered.
*/
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 { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
const DATASET_NAME = 'birth_names';
type ChartOutput = 'big-number' | 'table' | 'echarts';
type CreatedChart = DashboardLayoutChart & { output: ChartOutput };
async function canvasHasPaintedPixels(canvas: Locator): Promise<boolean> {
return canvas.evaluate((element: HTMLCanvasElement) => {
const context = element.getContext('2d');
if (!context || element.width === 0 || element.height === 0) {
return false;
}
const pixels = context.getImageData(0, 0, element.width, element.height);
for (let index = 3; index < pixels.data.length; index += 4) {
if (pixels.data[index] !== 0) {
return true;
}
}
return false;
});
}
async function expectChartOutput(
chart: Locator,
output: ChartOutput,
): Promise<void> {
if (output === 'big-number') {
const value = chart.locator(
'.superset-legacy-chart-big-number .header-line',
);
await expect(value).toBeVisible();
await expect(value).toHaveText(/\d/);
return;
}
if (output === 'table') {
await expect(
chart.locator('table tbody tr:not(:has(.dt-no-results))').first(),
).toBeVisible();
return;
}
const canvas = chart.locator('canvas').first();
await expect(canvas).toBeVisible();
await expect
.poll(() => canvasHasPaintedPixels(canvas), {
timeout: TIMEOUT.API_RESPONSE * 2,
message: 'ECharts canvas should contain painted pixels',
})
.toBe(true);
}
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.
const chartSpecs: {
viz_type: string;
output: ChartOutput;
params: Record<string, unknown>;
}[] = [
{
viz_type: 'big_number_total',
output: 'big-number',
params: { datasource, viz_type: 'big_number_total', metric: 'count' },
},
{
viz_type: 'table',
output: 'table',
params: {
datasource,
viz_type: 'table',
query_mode: 'aggregate',
groupby: ['name'],
metrics: ['count'],
row_limit: 100,
},
},
{
viz_type: 'echarts_timeseries_line',
output: 'echarts',
params: {
datasource,
viz_type: 'echarts_timeseries_line',
x_axis: 'ds',
time_grain_sqla: 'P1Y',
metrics: ['count'],
groupby: [],
row_limit: 100,
},
},
];
// 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),
});
expect(resp.ok()).toBe(true);
const chartId = await extractIdFromResponse(resp);
testAssets.trackChart(chartId);
charts.push({ id: chartId, sliceName, output: spec.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.
const chartDataStatusBySliceId = new Map<number, number>();
page.on('response', response => {
const request = response.request();
if (
request.method() !== 'POST' ||
!response.url().includes('/api/v1/chart/data')
) {
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 dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
// Assert the real terminal output for each known visualization rather than
// inferring completion from a generic wrapper shared by loading and errors.
for (const chart of charts) {
await expectChartOutput(dashboard.getChart(chart.id), chart.output);
}
// The render came from real backend queries: every chart issued its own
// chart-data POST and each one was accepted. 202 counts as accepted — with
// GLOBAL_ASYNC_QUERIES enabled a cold-cache query legitimately returns 202
// and delivers its result out of band. The render assertion above is what
// proves the data actually arrived, so this only needs to rule out a chart
// that never queried or was rejected outright.
for (const chartId of chartIds) {
const status = chartDataStatusBySliceId.get(chartId);
expect(
status,
`chart ${chartId} should have issued a /api/v1/chart/data POST`,
).toBeDefined();
expect(
[200, 202],
`chart ${chartId}'s /api/v1/chart/data response should be 200 or 202, got ${status}`,
).toContain(status);
}
},
);

View File

@@ -25,7 +25,10 @@
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPost, apiPut } from '../../helpers/api/requests';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import {
apiPostDashboard,
buildSingleRowDashboardLayout,
} from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import { DashboardPage } from '../../pages/DashboardPage';
@@ -73,36 +76,14 @@ testWithAssets(
const chartId: number = chart.id ?? chart.result?.id;
testAssets.trackChart(chartId);
const chartLayoutKey = `CHART-${chartId}`;
const positionJson = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-1'],
parents: ['ROOT_ID'],
const positionJson = buildSingleRowDashboardLayout([
{
id: chartId,
sliceName: 'display_control_repro',
width: 6,
height: 50,
},
'ROW-1': {
type: 'ROW',
id: 'ROW-1',
children: [chartLayoutKey],
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
[chartLayoutKey]: {
type: 'CHART',
id: chartLayoutKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
meta: {
chartId,
width: 6,
height: 50,
sliceName: 'display_control_repro',
},
},
};
]);
// 2. json_metadata: one dashboard filter + one Display Control.
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;

View File

@@ -36,7 +36,10 @@
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import {
apiPostDashboard,
buildSingleRowDashboardLayout,
} from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import { DashboardPage } from '../../pages/DashboardPage';
@@ -93,36 +96,9 @@ testWithAssets(
}
testAssets.trackChart(chartId);
const chartLayoutKey = `CHART-${chartId}`;
const positionJson = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-1'],
parents: ['ROOT_ID'],
},
'ROW-1': {
type: 'ROW',
id: 'ROW-1',
children: [chartLayoutKey],
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
[chartLayoutKey]: {
type: 'CHART',
id: chartLayoutKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
meta: {
chartId,
width: 6,
height: 60,
sliceName,
},
},
};
const positionJson = buildSingleRowDashboardLayout([
{ id: chartId, sliceName, width: 6, height: 60 },
]);
const dashResp = await apiPostDashboard(page, {
dashboard_title: `gauge_interval_colors_${Date.now()}`,
published: true,

View File

@@ -35,7 +35,10 @@
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPost, apiPut } from '../../helpers/api/requests';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import {
apiPostDashboard,
buildSingleRowDashboardLayout,
} from '../../helpers/api/dashboard';
import { DashboardPage } from '../../pages/DashboardPage';
const DATASET_NAME = 'birth_names';
@@ -86,37 +89,15 @@ testWithAssets(
const chartId: number = (await chartResp.json()).id;
testAssets.trackChart(chartId);
const chartLayoutKey = `CHART-${chartId}`;
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
const positionJson = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-1'],
parents: ['ROOT_ID'],
const positionJson = buildSingleRowDashboardLayout([
{
id: chartId,
sliceName: 'mixed_filter_repro',
width: 8,
height: 60,
},
'ROW-1': {
type: 'ROW',
id: 'ROW-1',
children: [chartLayoutKey],
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
[chartLayoutKey]: {
type: 'CHART',
id: chartLayoutKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
meta: {
chartId,
width: 8,
height: 60,
sliceName: 'mixed_filter_repro',
},
},
};
]);
const jsonMetadata = {
native_filter_configuration: [
{

View File

@@ -53,6 +53,7 @@ import { apiPost, apiPut } from '../../helpers/api/requests';
import {
apiPostDashboard,
apiDeleteDashboard,
buildSingleRowDashboardLayout,
} from '../../helpers/api/dashboard';
import { apiDeleteChart } from '../../helpers/api/chart';
import { EmbeddedPage } from '../../pages/EmbeddedPage';
@@ -190,36 +191,14 @@ test.describe('Embedded Pivot Table collapse state (#33406)', () => {
});
chartId = (await chartResp.json()).id;
const chartLayoutKey = `CHART-${chartId}`;
const positionJson = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-1'],
parents: ['ROOT_ID'],
const positionJson = buildSingleRowDashboardLayout([
{
id: chartId,
sliceName: 'pivot_collapse_repro',
width: 6,
height: 80,
},
'ROW-1': {
type: 'ROW',
id: 'ROW-1',
children: [chartLayoutKey],
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
[chartLayoutKey]: {
type: 'CHART',
id: chartLayoutKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
meta: {
chartId,
width: 6,
height: 80,
sliceName: 'pivot_collapse_repro',
},
},
};
]);
const dashResp = await apiPostDashboard(setupPage, {
dashboard_title: `pivot_collapse_repro_${Date.now()}`,
published: true,