This is an automated email from the ASF dual-hosted git repository. sadpandajoe pushed a commit to branch test-featured-charts-smoke in repository https://gitbox.apache.org/repos/asf/superset.git
commit 44c1e5f7ef2eedb75864fa2c01c8e4029ca0ab24 Author: Joe Li <[email protected]> AuthorDate: Wed Sep 30 17:25:28 2026 -0700 test(playwright): add Featured Charts render smoke and Explore image download Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../playwright/helpers/api/dashboard.ts | 27 +++ superset-frontend/playwright/pages/ExplorePage.ts | 23 ++- .../tests/dashboard/featured-charts-render.spec.ts | 209 +++++++++++++++++++++ .../tests/explore/explore-download-image.spec.ts | 65 +++++++ 4 files changed, 323 insertions(+), 1 deletion(-) diff --git a/superset-frontend/playwright/helpers/api/dashboard.ts b/superset-frontend/playwright/helpers/api/dashboard.ts index d696518d30e..e2c07b00bab 100644 --- a/superset-frontend/playwright/helpers/api/dashboard.ts +++ b/superset-frontend/playwright/helpers/api/dashboard.ts @@ -160,6 +160,33 @@ export async function apiGetDashboard( return apiGet(page, `${ENDPOINTS.DASHBOARD}${dashboardId}`, options); } +/** + * A chart placed on a dashboard, as returned by the dashboard charts endpoint. + */ +export interface DashboardChartResult { + id: number; + slice_name: string; + form_data: { viz_type: string } & Record<string, unknown>; +} + +/** + * Fetch every chart placed on a dashboard. + * @param page - Playwright page instance (provides authentication context) + * @param dashboardId - ID of the dashboard whose charts to fetch + * @returns The dashboard's charts + */ +export async function getDashboardCharts( + page: Page, + dashboardId: number, +): Promise<DashboardChartResult[]> { + const response = await apiGet( + page, + `${ENDPOINTS.DASHBOARD}${dashboardId}/charts`, + ); + const body: { result: DashboardChartResult[] } = await response.json(); + return body.result; +} + /** * GET request to resolve a native-filter `filter_state` key for a dashboard. * The key is the `native_filters_key` URL param the filter bar publishes; a diff --git a/superset-frontend/playwright/pages/ExplorePage.ts b/superset-frontend/playwright/pages/ExplorePage.ts index 537496e0db2..a0a689a8719 100644 --- a/superset-frontend/playwright/pages/ExplorePage.ts +++ b/superset-frontend/playwright/pages/ExplorePage.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Page, Locator } from '@playwright/test'; +import { Download, Page, Locator } from '@playwright/test'; import { TIMEOUT } from '../utils/constants'; import { AgGrid } from '../components/core/AgGrid'; import { Button, Menu } from '../components/core'; @@ -247,4 +247,25 @@ export class ExplorePage { await this.openActionsMenu(); return this.actionsMenu().openSubmenu('On dashboards'); } + + /** + * Selects an item under the actions menu's + * Data Export Options > Export All Data submenu and returns the resulting + * browser download. + * + * @param itemText - The export item to select (e.g., "Export screenshot (jpeg)") + */ + async downloadFromExportAllData(itemText: string): Promise<Download> { + await this.openActionsMenu(); + const dataExportPopup = await this.actionsMenu().openSubmenu( + 'Data Export Options', + { itemText: 'Export All Data' }, + ); + const downloadPromise = this.page.waitForEvent('download'); + await new Menu(this.page, dataExportPopup).selectSubmenuItem( + 'Export All Data', + itemText, + ); + return downloadPromise; + } } diff --git a/superset-frontend/playwright/tests/dashboard/featured-charts-render.spec.ts b/superset-frontend/playwright/tests/dashboard/featured-charts-render.spec.ts new file mode 100644 index 00000000000..d012abbd3e9 --- /dev/null +++ b/superset-frontend/playwright/tests/dashboard/featured-charts-render.spec.ts @@ -0,0 +1,209 @@ +/** + * 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. + */ + +/** + * Render smoke for the "Featured Charts" example dashboard. + * + * Replaces the per-visualization Cypress specs (big number, box plot, bubble, + * gauge, graph, pie, sunburst, ...) that were skipped once example data moved + * to YAML and later deleted. Those specs mostly asserted that a canvas or svg + * existed; chart correctness lives in each plugin's transformProps/buildQuery + * unit tests. This spec keeps the one thing only a real browser against a real + * backend can prove: every registered visualization on the example dashboard + * queries successfully and paints its output without an error. + * + * CI green => every chart on the dashboard issued an accepted chart-data query + * and rendered its terminal output with no error alert. + * CI red => a visualization failed to query or render, or the example + * dashboard lost one of the visualization types it covers. + */ +import type { Locator } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import { + getDashboardByName, + getDashboardCharts, +} from '../../helpers/api/dashboard'; +import { DashboardPage } from '../../pages/DashboardPage'; +import { TIMEOUT } from '../../utils/constants'; +import { sliceIdFromChartDataUrl } from './dashboard-test-helpers'; + +const DASHBOARD_TITLE = 'Featured Charts'; + +// Visualization types the dashboard is expected to cover. A subset check, so +// adding charts to the example does not break the spec, but dropping one does. +const EXPECTED_VIZ_TYPES = [ + 'big_number', + 'big_number_total', + 'box_plot', + 'bubble_v2', + 'echarts_area', + 'echarts_timeseries_bar', + 'echarts_timeseries_line', + 'echarts_timeseries_scatter', + 'funnel', + 'gantt_chart', + 'gauge_chart', + 'graph_chart', + 'heatmap_v2', + 'histogram_v2', + 'mixed_timeseries', + 'pie', + 'pivot_table_v2', + 'radar', + 'sankey_v2', + 'sunburst_v2', + 'table', + 'tree_chart', + 'treemap_v2', + 'waterfall', + 'word_cloud', +]; + +// Saturated-pixel floor for a chart's canvases. Painted charts on this +// dashboard score from several hundred to tens of thousands; a blank or +// axes-only canvas scores ~0. Series correctness is left to plugin unit tests. +const MIN_COLORED_PIXELS = 100; + +/** + * The terminal output each visualization paints once its data has arrived. + * ECharts-based visualizations (the default) paint a canvas. + */ +function chartOutput(chart: Locator, vizType: string): Locator { + switch (vizType) { + case 'big_number': + case 'big_number_total': + return chart.locator('.header-line'); + case 'table': + case 'pivot_table_v2': + return chart.locator('table tbody tr:not(:has(.dt-no-results))').first(); + case 'word_cloud': + return chart.locator('svg text').first(); + default: + return chart.locator('canvas').first(); + } +} + +/** + * Counts opaque, saturated pixels across every canvas in a chart (ECharts may + * split a chart over several layers). Axes, gridlines and labels are drawn in + * neutral grays, so only colored marks such as series and legend swatches + * count. + */ +async function saturatedPixelCount(chart: Locator): Promise<number> { + return chart.locator('canvas').evaluateAll((elements: HTMLCanvasElement[]) => + elements.reduce((total, element) => { + const context = element.getContext('2d'); + if (!context || element.width === 0 || element.height === 0) { + return total; + } + const { data } = context.getImageData( + 0, + 0, + element.width, + element.height, + ); + let count = 0; + for (let index = 0; index < data.length; index += 4) { + const red = data[index]; + const green = data[index + 1]; + const blue = data[index + 2]; + if ( + data[index + 3] >= 200 && + Math.max(red, green, blue) - Math.min(red, green, blue) > 40 + ) { + count += 1; + } + } + return total + count; + }, 0), + ); +} + +test('every chart on the Featured Charts dashboard renders', async ({ + page, +}) => { + // Twenty-five uncached queries, rendered one scroll position at a time. + test.setTimeout(TIMEOUT.SLOW_TEST * 3); + + const dashboardSummary = await getDashboardByName(page, DASHBOARD_TITLE); + expect( + dashboardSummary, + `the "${DASHBOARD_TITLE}" example dashboard should be loaded`, + ).not.toBeNull(); + const dashboardId = dashboardSummary!.id; + + const charts = await getDashboardCharts(page, dashboardId); + const vizTypes = charts.map(chart => chart.form_data.viz_type); + expect(vizTypes).toEqual(expect.arrayContaining(EXPECTED_VIZ_TYPES)); + + const chartDataStatusBySliceId = new Map<number, number>(); + page.on('response', response => { + if ( + response.request().method() !== 'POST' || + !response.url().includes('/api/v1/chart/data') + ) { + return; + } + const sliceId = sliceIdFromChartDataUrl(response.url()); + if (sliceId !== undefined) { + chartDataStatusBySliceId.set(sliceId, response.status()); + } + }); + + const dashboard = new DashboardPage(page); + await dashboard.gotoById(dashboardId); + await dashboard.waitForLoad(); + + // Dashboard virtualization only mounts rows near the viewport, so bring each + // chart into view (as a user scrolling the dashboard would) before asserting. + for (const { id, slice_name: name, form_data: formData } of charts) { + const chart = dashboard.getChart(id); + await chart.scrollIntoViewIfNeeded(); + + const output = chartOutput(chart, formData.viz_type); + await expect( + output, + `"${name}" (${formData.viz_type}) should render its output`, + ).toBeVisible({ timeout: TIMEOUT.CHART_RENDER }); + if (formData.viz_type.startsWith('big_number')) { + await expect(output).toHaveText(/\d/); + } + if ((await output.evaluate(element => element.tagName)) === 'CANVAS') { + await expect + .poll(() => saturatedPixelCount(chart), { + timeout: TIMEOUT.CHART_RENDER, + message: `"${name}" (${formData.viz_type}) should paint colored marks`, + }) + .toBeGreaterThan(MIN_COLORED_PIXELS); + } + await expect( + chart.getByRole('alert'), + `"${name}" (${formData.viz_type}) should not show an error`, + ).toHaveCount(0); + + // 202 counts as accepted: with GLOBAL_ASYNC_QUERIES a cold-cache query + // returns 202 and delivers its result out of band. The render assertion + // above proves the data arrived. + const status = chartDataStatusBySliceId.get(id); + expect( + [200, 202], + `"${name}" chart-data response should be 200 or 202, got ${status}`, + ).toContain(status); + } +}); diff --git a/superset-frontend/playwright/tests/explore/explore-download-image.spec.ts b/superset-frontend/playwright/tests/explore/explore-download-image.spec.ts new file mode 100644 index 00000000000..f97c5e83f48 --- /dev/null +++ b/superset-frontend/playwright/tests/explore/explore-download-image.spec.ts @@ -0,0 +1,65 @@ +/** + * 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. + */ + +/** + * Replaces the Cypress "Download Chart > download chart with image works" + * spec, which was skipped once its hardcoded slice id stopped resolving. The + * image capture itself is unit tested (src/utils/downloadAsImage.test.ts); + * this proves the Explore menu wiring produces a real browser download. + */ +import { readFile } from 'fs/promises'; +import { testWithAssets, expect } from '../../helpers/fixtures'; +import { ExplorePage } from '../../pages/ExplorePage'; +import { TIMEOUT } from '../../utils/constants'; +import { createExploreTestChart } from './explore-test-helpers'; + +const JPEG_MAGIC = [0xff, 0xd8, 0xff]; + +testWithAssets( + 'exporting a chart screenshot as jpeg downloads a jpeg file', + async ({ page, testAssets }, testInfo) => { + const { id: chartId } = await createExploreTestChart( + page, + testAssets, + testInfo, + { prefix: 'explore_download_image' }, + ); + + const explorePage = new ExplorePage(page); + await explorePage.goto(chartId); + // Capture only once the chart has painted, not its loading state. + await expect( + explorePage + .getChartContainer() + .locator('table tbody tr:not(:has(.dt-no-results))') + .first(), + ).toBeVisible({ timeout: TIMEOUT.CHART_RENDER }); + + const download = await explorePage.downloadFromExportAllData( + 'Export screenshot (jpeg)', + ); + + // The file is named after the kebab-cased chart name. + expect(download.suggestedFilename()).toMatch( + /^explore-download-image-.*\.jpg$/, + ); + const bytes = await readFile(await download.path()); + expect([...bytes.subarray(0, 3)]).toEqual(JPEG_MAGIC); + }, +);
