Compare commits

..

1 Commits

Author SHA1 Message Date
Joe Li
8435a9ca42 test(dashboard): migrate edit-mode component E2E tests to Playwright
Port the Cypress dashboard edit-mode 'Components' tests (add chart, remove
chart, add markdown) to Playwright as true E2E. The dashboard grid uses
react-dnd with the HTML5 backend, so drags are driven by synthetic native
drag events with a shared DataTransfer (new helpers/dnd.ts). Tests are
hermetic: charts/dashboards are created from birth_names via the API and
cleaned up by the testAssets fixture.

The 21 skipped 'Color consistency' Cypress tests are intentionally not
ported: they assert per-series colors via an .nv-legend-symbol SVG fill that
no longer exists (ECharts renders to canvas, not DOM-inspectable); that
color-precedence logic is covered by Jest/RTL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 21:06:24 -07:00
10 changed files with 407 additions and 85 deletions

View File

@@ -122,13 +122,25 @@ jobs:
pattern: coverage-artifacts-*
path: coverage/
- name: Reorganize test result reports
run: |
find coverage/
for i in {1..8}; do
mv coverage/coverage-artifacts-${i}/coverage-final.json coverage/coverage-shard-${i}.json
done
shell: bash
- name: Merge Code Coverage
run: npx nyc merge coverage/ merged-output/coverage-summary.json
- name: Upload Code Coverage
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: javascript
use_oidc: true
verbose: true
directory: coverage
disable_search: true
files: merged-output/coverage-summary.json
slug: apache/superset
lint-frontend:

View File

@@ -54,7 +54,7 @@ dependencies = [
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <4.0.0",
"flask-appbuilder>=5.2.2, <6.0.0",
"flask-caching>=2.4.1, <3",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
"flask-login>=0.6.0, < 1.0",
@@ -92,7 +92,7 @@ dependencies = [
"Pillow>=11.0.0, <13",
"polyline>=2.0.0, <3.0",
"pydantic>=2.8.0",
"pyparsing>=3.3.2, <4",
"pyparsing>=3.0.6, <4",
"python-dateutil",
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
"pygeohash",
@@ -127,7 +127,7 @@ aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
bigquery = [
"pandas-gbq>=0.35.0",
"sqlalchemy-bigquery>=1.17.0",
"google-cloud-bigquery>=3.42.2",
"google-cloud-bigquery>=3.42.1",
]
clickhouse = ["clickhouse-connect>=1.4.2, <2.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
@@ -142,8 +142,8 @@ databricks = [
"databricks-sql-connector>=4.2.6, <4.4.0",
"databricks-sqlalchemy==1.0.5",
]
datafusion = ["flightsql-dbapi>=0.2.2, <0.3"]
db2 = ["ibm-db-sa<=0.4.4, >=0.4.4"]
datafusion = ["flightsql-dbapi>=0.2.0, <0.3"]
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
denodo = ["denodo-sqlalchemy>=2.0.5,<2.1.0"]
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
drill = ["sqlalchemy-drill>=1.1.10, <2"]
@@ -189,7 +189,7 @@ ocient = [
"shapely",
"geojson",
]
oracle = ["oracledb>=4.0.2, <5"]
oracle = ["oracledb>=2.0.0, <5"]
parseable = ["sqlalchemy-parseable>=0.1.6,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.61.0, <2"]
@@ -201,7 +201,7 @@ redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
risingwave = ["sqlalchemy-risingwave"]
shillelagh = ["shillelagh[all]>=1.4.4, <2"]
singlestore = ["sqlalchemy-singlestoredb>=1.2.1, <2"]
snowflake = ["snowflake-sqlalchemy>=1.11.0, <2"]
snowflake = ["snowflake-sqlalchemy>=1.10.2, <2"]
sqlite = ["syntaqlite>=0.7.0,<0.8.0"]
spark = [
"pyhive[hive_pure_sasl]>=0.7",
@@ -227,7 +227,7 @@ development = [
"docker",
"flask-testing",
"freezegun",
"grpcio>=1.82.1",
"grpcio>=1.81.1",
"openapi-spec-validator",
"parameterized",
"pip",

View File

@@ -130,7 +130,7 @@ flask-appbuilder==5.2.2
# apache-superset-core
flask-babel==3.1.0
# via flask-appbuilder
flask-caching==2.4.1
flask-caching==2.3.1
# via apache-superset (pyproject.toml)
flask-compress==1.24
# via apache-superset (pyproject.toml)
@@ -329,7 +329,7 @@ pyopenssl==26.3.0
# via
# -r requirements/base.in
# shillelagh
pyparsing==3.3.2
pyparsing==3.2.3
# via apache-superset (pyproject.toml)
pysocks==1.7.1
# via urllib3

View File

@@ -276,7 +276,7 @@ flask-babel==3.1.0
# via
# -c requirements/base-constraint.txt
# flask-appbuilder
flask-caching==2.4.1
flask-caching==2.3.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -341,7 +341,7 @@ geopy==2.4.1
# apache-superset
gevent==26.4.0
# via apache-superset
google-api-core==2.33.0
google-api-core==2.23.0
# via
# google-cloud-bigquery
# google-cloud-core
@@ -362,7 +362,7 @@ google-auth-oauthlib==1.2.1
# via
# pandas-gbq
# pydata-google-auth
google-cloud-bigquery==3.42.2
google-cloud-bigquery==3.42.1
# via
# apache-superset
# pandas-gbq
@@ -386,7 +386,7 @@ greenlet==3.5.3
# sqlalchemy
griffelib==2.0.2
# via fastmcp-slim
grpcio==1.83.0
grpcio==1.81.1
# via
# apache-superset
# google-api-core
@@ -795,7 +795,7 @@ pyopenssl==26.3.0
# -c requirements/base-constraint.txt
# google-auth
# shillelagh
pyparsing==3.3.2
pyparsing==3.2.3
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -66,6 +66,12 @@ const StyledTable = styled(Table)<{
showRowCount?: boolean;
}>`
${({ theme, isPaginationSticky, showRowCount }) => `
th.ant-column-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
opacity: 0;
font-size: ${theme.fontSizeXL}px;

View File

@@ -0,0 +1,66 @@
/**
* 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.
*/
import { Locator, Page } from '@playwright/test';
/**
* Drives an HTML5 drag-and-drop using synthetic native drag events.
*
* The dashboard grid uses react-dnd with the HTML5 backend
* (`react-dnd-html5-backend`), which listens for native `dragstart` /
* `dragenter` / `dragover` / `drop` events rather than the mouse events that
* Playwright's built-in `locator.dragTo()` produces. To trigger it we dispatch
* the native drag sequence ourselves, threading a single shared `DataTransfer`
* object through every event so react-dnd's monitor sees a consistent payload.
*
* Mirrors the synthetic-event sequence used by the deprecated Cypress `drag`
* helper (cypress-base/cypress/utils/index.ts).
*
* @param page - Playwright page (used to mint the shared DataTransfer)
* @param source - The draggable element (or a descendant; drag events bubble)
* @param target - The drop target element
*/
export async function html5DragAndDrop(
page: Page,
source: Locator,
target: Locator,
): Promise<void> {
// Note: we intentionally do not scrollIntoView the source. The chart card list
// is virtualized, so a separate scroll action can detach the element between
// resolution and use; dispatchEvent only requires the node to be attached.
// A single DataTransfer shared across every event in the sequence: react-dnd's
// HTML5 backend reads/writes drag state through it, so reusing one handle is
// what makes the monitor treat this as one coherent drag.
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
await source.dispatchEvent('dragstart', { dataTransfer });
// react-dnd's HTML5 backend commits monitor state (the active drag source) on a
// microtask after dragstart; a short settle avoids a race where dragover/drop
// fire before the backend considers a drag to be in progress.
await page.waitForTimeout(50);
// dragenter must precede dragover for react-dnd to register the hover target.
await target.dispatchEvent('dragenter', { dataTransfer });
await target.dispatchEvent('dragover', { dataTransfer });
await page.waitForTimeout(50);
await target.dispatchEvent('drop', { dataTransfer });
await source.dispatchEvent('dragend', { dataTransfer });
await dataTransfer.dispose();
}

View File

@@ -18,9 +18,10 @@
*/
import { Page, Download, Locator } from '@playwright/test';
import { Menu } from '../components/core';
import { Button, Input, Menu, Tabs } from '../components/core';
import { NativeFiltersConfigModal } from '../components/modals';
import { gotoWithRetry } from '../helpers/navigation';
import { html5DragAndDrop } from '../helpers/dnd';
import { TIMEOUT } from '../utils/constants';
/**
@@ -37,6 +38,16 @@ export class DashboardPage {
FILTER_BAR_SETTINGS: '[data-test="filterbar-orientation-icon"]',
APPLY_FILTERS_BUTTON:
'[data-test="filter-bar__apply-button"], [data-test="filterbar-action-buttons"] button[type="submit"]',
EDIT_BUTTON: '[data-test="edit-dashboard-button"]',
BUILDER_PANE: '[data-test="dashboard-builder-sidepane"]',
CHARTS_SEARCH: '[data-test="dashboard-charts-filter-search-input"]',
CHART_CARD: '[data-test="chart-card"]',
GRID_CONTENT: '[data-test="grid-content"]',
EMPTY_DROPTARGET: '[data-test="grid-content"] .empty-droptarget',
NEW_COMPONENT: '[data-test="new-component"]',
CHART_HOLDER: '[data-test="dashboard-component-chart-holder"]',
DELETE_COMPONENT: '[data-test="dashboard-delete-component-button"]',
MARKDOWN_EDITOR: '[data-test="dashboard-markdown-editor"]',
} as const;
constructor(page: Page) {
@@ -199,4 +210,106 @@ export class DashboardPage {
await menu.selectSubmenuItem('Download', optionText);
return downloadPromise;
}
/**
* Enter dashboard edit mode and wait for the builder side pane to appear.
*/
async enterEditMode(): Promise<void> {
const editButton = new Button(
this.page,
DashboardPage.SELECTORS.EDIT_BUTTON,
);
await editButton.click();
await this.page.waitForSelector(DashboardPage.SELECTORS.BUILDER_PANE, {
state: 'visible',
});
}
/**
* The builder side pane's tab bar (Charts / Layout elements).
*/
private builderTabs(): Tabs {
return new Tabs(
this.page,
this.page
.locator(`${DashboardPage.SELECTORS.BUILDER_PANE} .ant-tabs`)
.first(),
);
}
/**
* Switch the builder side pane to one of its tabs.
* @param tab - 'Charts' (existing slices) or 'Layout elements' (new components)
*/
async openBuilderTab(tab: 'Charts' | 'Layout elements'): Promise<void> {
await this.builderTabs().clickTab(tab);
}
/**
* Locator for chart-holder components currently placed on the grid.
*/
chartHolders(): Locator {
return this.page.locator(DashboardPage.SELECTORS.CHART_HOLDER);
}
/**
* Drag an existing chart from the Charts pane onto the dashboard grid.
* Requires edit mode to be active.
* @param sliceName - The slice name to search for and drag
*/
async addChartByName(sliceName: string): Promise<void> {
await this.openBuilderTab('Charts');
const search = new Input(this.page, DashboardPage.SELECTORS.CHARTS_SEARCH);
await search.fill(sliceName);
const card = this.page
.locator(DashboardPage.SELECTORS.CHART_CARD)
.filter({ hasText: sliceName })
.first();
await card.waitFor({ state: 'visible' });
await html5DragAndDrop(this.page, card, this.dropTarget());
}
/**
* Drag a new Layout element (by its label) onto the dashboard grid.
* Requires edit mode to be active.
* @param label - The new-component label, e.g. 'Text / Markdown'
*/
async addLayoutElement(label: string): Promise<void> {
await this.openBuilderTab('Layout elements');
const source = this.page
.locator(DashboardPage.SELECTORS.NEW_COMPONENT)
.filter({ hasText: label })
.first();
await source.waitFor({ state: 'visible' });
await html5DragAndDrop(this.page, source, this.dropTarget());
}
/**
* The grid drop target. Prefers the empty droptarget (empty grid) and falls
* back to the grid content container.
*/
private dropTarget(): Locator {
return this.page.locator(DashboardPage.SELECTORS.EMPTY_DROPTARGET).first();
}
/**
* Hover a placed chart-holder and click its delete button (edit mode).
* @param index - Which chart holder to delete (default 0)
*/
async deleteChartHolder(index = 0): Promise<void> {
const holder = this.chartHolders().nth(index);
await holder.hover();
const deleteButton = new Button(
this.page,
holder.locator(DashboardPage.SELECTORS.DELETE_COMPONENT),
);
await deleteButton.click();
}
/**
* Locator for markdown editor components on the grid.
*/
markdownEditors(): Locator {
return this.page.locator(DashboardPage.SELECTORS.MARKDOWN_EDITOR);
}
}

View File

@@ -0,0 +1,192 @@
/**
* 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.
*/
/**
* Dashboard edit-mode component tests — migrated from the deprecated Cypress
* suite (cypress-base/cypress/e2e/dashboard/editmode.test.ts, "Components"
* block). These cover the chart/markdown drag-and-drop workflows that the
* upstream Cypress notes flagged as the one part of edit mode that genuinely
* requires E2E coverage ("Chart drag/drop functionality requires true E2E
* testing"). The grid uses react-dnd with the HTML5 backend, so drags are
* driven by synthetic native drag events (see helpers/dnd.ts).
*
* The 21 skipped "Color consistency" tests from the same Cypress file are NOT
* migrated here: they assert per-series colors by reading an `.nv-legend-symbol`
* SVG `fill` attribute that no longer exists (ECharts renders to <canvas>, which
* is not DOM-inspectable — the upstream FIXME skipped them for exactly this
* reason). The underlying color-precedence logic is covered by Jest/RTL.
*/
import {
testWithAssets,
expect,
type TestAssets,
} from '../../helpers/fixtures';
import { apiPost } from '../../helpers/api/requests';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import { DashboardPage } from '../../pages/DashboardPage';
import type { Page } from '@playwright/test';
const DATASET_NAME = 'birth_names';
async function findDatasetIdByName(page: Page, name: string): Promise<number> {
const rison = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
const resp = await page.request.get(`api/v1/dataset/?q=${rison}`);
const body = await resp.json();
if (!body.result?.length) {
throw new Error(`Dataset ${name} not found`);
}
return body.result[0].id;
}
/** Create a hermetic chart from birth_names, NOT placed on any dashboard. */
async function createChart(
page: Page,
testAssets: TestAssets,
): Promise<string> {
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
const sliceName = `edit_mode_chart_${Date.now()}_${Math.floor(
performance.now(),
)}`;
const resp = await apiPost(page, 'api/v1/chart/', {
slice_name: sliceName,
viz_type: 'big_number_total',
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify({
datasource: `${datasetId}__table`,
viz_type: 'big_number_total',
metric: 'count',
}),
});
expect(resp.ok()).toBe(true);
testAssets.trackChart((await resp.json()).id);
return sliceName;
}
/** Create an empty published dashboard and return its id. */
async function createDashboard(
page: Page,
testAssets: TestAssets,
): Promise<number> {
const resp = await apiPostDashboard(page, {
dashboard_title: `edit_mode_${Date.now()}_${Math.floor(performance.now())}`,
published: true,
});
expect(resp.ok()).toBe(true);
const body = await resp.json();
const id: number = body.result?.id ?? body.id;
testAssets.trackDashboard(id);
return id;
}
testWithAssets(
'edit mode: add a chart to the dashboard via drag-and-drop',
async ({ page, testAssets }) => {
const sliceName = await createChart(page, testAssets);
const dashboardId = await createDashboard(page, testAssets);
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard.enterEditMode();
await expect(dashboard.chartHolders()).toHaveCount(0);
await dashboard.addChartByName(sliceName);
await expect(dashboard.chartHolders()).toHaveCount(1);
},
);
testWithAssets(
'edit mode: remove an added chart from the dashboard',
async ({ page, testAssets }) => {
const sliceName = await createChart(page, testAssets);
const dashboardId = await createDashboard(page, testAssets);
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard.enterEditMode();
await dashboard.addChartByName(sliceName);
await expect(dashboard.chartHolders()).toHaveCount(1);
await dashboard.deleteChartHolder();
await expect(dashboard.chartHolders()).toHaveCount(0);
},
);
testWithAssets(
'edit mode: add a markdown component via drag-and-drop',
async ({ page, testAssets }) => {
// Heaviest edit-mode flow (drag + ace edit + commit + mouse resize); give it
// extra headroom so it stays reliable when the suite runs in parallel.
testWithAssets.slow();
const dashboardId = await createDashboard(page, testAssets);
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard.enterEditMode();
await dashboard.addLayoutElement('Text / Markdown');
const editor = dashboard.markdownEditors().first();
await expect(editor).toBeVisible();
// Enter edit mode by focusing the component. The markdown enters edit on a
// document-level focus handler attached after mount, so a single early click
// can be missed under load; retry until the ace editor appears. Click the
// rendered "Header 1" heading element specifically (never the trailing
// hyperlink in the default content), so a stray click can't navigate away.
const aceContent = editor.locator('.ace_content');
const heading = editor.locator('h1', { hasText: 'Header 1' });
await expect(async () => {
if (await aceContent.isVisible()) return;
await heading.click();
await expect(aceContent).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: 20000 });
await expect(aceContent).toContainText('Header 1');
await expect(aceContent).toContainText('markdown formatting');
// Replace the content and confirm the edit is reflected.
const aceInput = editor.locator('.ace_text-input');
await aceInput.press('ControlOrMeta+a');
await aceInput.press('Delete');
await aceInput.type('Test resize');
await expect(aceContent).toContainText('Test resize');
// Commit by clicking outside the component; the preview keeps the text.
const boxBefore = await editor.boundingBox();
await page.locator('[data-test="editable-title-input"]').first().click();
await expect(editor).toContainText('Test resize');
// Resize via the bottom handle and confirm the component grew taller.
const handle = editor.locator('.resizable-container-handle--bottom').last();
const hb = await handle.boundingBox();
expect(hb).not.toBeNull();
if (hb && boxBefore) {
await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
await page.mouse.down();
await page.mouse.move(hb.x + hb.width / 2, hb.y + 150, { steps: 10 });
await page.mouse.up();
const boxAfter = await editor.boundingBox();
expect(boxAfter!.height).toBeGreaterThan(boxBefore.height);
}
},
);

View File

@@ -1,67 +0,0 @@
/**
* 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.
*/
import { executeQuery } from './actions';
import fetchMock from 'fetch-mock';
fetchMock.post('glob:*/sqllab/execute', { result: [] });
afterAll(() => {
fetchMock.clearHistory().removeRoutes();
});
test('executeQuery', async () => {
const mockDispatch = jest.fn();
const mockedQueryExecutePayload = {
client_id: 'client_id_1',
database_id: 1,
runAsync: false,
catalog: null,
schema: 'schema_1',
sql: '1',
tmp_table_name: 'tmp_table_1',
select_as_cta: false,
ctas_method: 'SELECT',
queryLimit: 10,
expand_data: false,
};
const returnedDispatchFunc = executeQuery(mockedQueryExecutePayload);
await returnedDispatchFunc(mockDispatch);
const [
[setQueryIsLoadingActionObject],
[setQueryResultActionObject],
[setQueryIsNotLoadingActionObject],
] = mockDispatch.mock.calls;
expect(setQueryIsLoadingActionObject).toStrictEqual({
type: 'SET_QUERY_IS_LOADING',
payload: true,
});
expect(setQueryResultActionObject).toStrictEqual({
type: 'SET_QUERY_RESULT',
payload: {
result: [],
},
});
expect(setQueryIsNotLoadingActionObject).toStrictEqual({
type: 'SET_QUERY_IS_LOADING',
payload: false,
});
});

View File

@@ -67,7 +67,7 @@ export function executeQuery(payload: QueryExecutePayload) {
const result = await executeQueryApi(payload);
dispatch(setQueryResult(result as QueryExecuteResponse));
} catch (error) {
dispatch(setQueryError((error as Error).message));
dispatch(setQueryError(error.message));
} finally {
dispatch(setQueryIsLoading(false));
}