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);
+  },
+);

Reply via email to