Compare commits

...

3 Commits

Author SHA1 Message Date
Joe Li
24ea688158 style(dashboard): wrap topLevelTabs locator to satisfy prettier
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:11:30 +00:00
Joe Li
a3bbe119aa test(dashboard): scope top-level tab locator and use typed dataset helper
- topLevelTabs() now scopes the nav list to the immediate child (:scope >)
  so tabs from nested tab bars rendered inside the container content are not
  counted by .count()/.nth() callers.
- Replace local findDatasetIdByName(page: any) with the typed
  getDatasetByName(page: Page) helper (retry-wrapped apiGet, no new any).
Addresses review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:11:30 +00:00
Joe Li
9469b1a786 test(dashboard): migrate tabs resize E2E to Playwright
Migrate the genuinely end-to-end case from the deprecated Cypress
"Dashboard tabs" suite to the Playwright framework: a chart in a hidden
tab must re-measure and refit its container when the tab is revealed
after the available width changed.

The dashboard is built hermetically (two top-level tabs, a width-
sensitive treemap in the first) via the API and the viewport is shrunk
while the treemap is hidden — replacing the original's dependency on a
seeded tabbed dashboard and native-filter-bar expansion. The test
asserts both that the container actually reflowed (guarding against a
trivial green) and that the chart refit with no horizontal overflow.

The other two original cases asserted only the active-tab CSS class and
query-on-visible behaviour (one already skipped); those are DOM/state
assertions with no backend invariant and are intentionally not migrated.

Adds DashboardPage.topLevelTabs()/switchToTopLevelTab() helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:11:30 +00:00
2 changed files with 306 additions and 1 deletions

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { Page, Download } from '@playwright/test';
import { Page, Download, Locator } from '@playwright/test';
import { Menu } from '../components/core';
import { gotoWithRetry } from '../helpers/navigation';
import { TIMEOUT } from '../utils/constants';
@@ -33,6 +33,7 @@ export class DashboardPage {
DASHBOARD_MENU_TRIGGER: '[data-test="actions-trigger"]',
// The header-actions-menu is the data-test for the dropdown menu content
HEADER_ACTIONS_MENU: '[data-test="header-actions-menu"]',
DASHBOARD_TABS: '[data-test="dashboard-component-tabs"]',
} as const;
constructor(page: Page) {
@@ -94,6 +95,34 @@ export class DashboardPage {
);
}
/**
* Locator for the individual tabs of the top-level tab bar.
* A dashboard can contain several nested tab bars; the top-level one is the
* first `dashboard-component-tabs` rendered in the DOM. The nav list is
* scoped to the immediate child (`:scope >`) so that tabs belonging to nested
* tab bars rendered inside this container's content are not counted.
*/
topLevelTabs(): Locator {
return this.page
.locator(DashboardPage.SELECTORS.DASHBOARD_TABS)
.first()
.locator(
':scope > [data-test="nav-list"] .ant-tabs-nav-list > .ant-tabs-tab',
);
}
/**
* Switch to the nth top-level tab (0-indexed) and wait for it to become active.
*/
async switchToTopLevelTab(index: number): Promise<void> {
const tab = this.topLevelTabs().nth(index);
await tab.click();
await tab.and(this.page.locator('.ant-tabs-tab-active')).waitFor({
state: 'attached',
timeout: TIMEOUT.API_RESPONSE,
});
}
/**
* Open the dashboard header actions menu (three-dot menu)
*/

View File

@@ -0,0 +1,276 @@
/**
* 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 tabs" suite (dashboard/tabs.test.ts).
*
* Only one of the three original cases is a genuine end-to-end behaviour:
* "should update size when switch tab". A chart living in an inactive (hidden)
* tab must re-measure and refit its container when the tab is revealed after the
* available width has changed — a real browser layout-reflow that can only be
* exercised against a rendered chart in a real dashboard. The other two cases
* ("should switch tabs" asserted only the `ant-tabs-tab-active` CSS class, and
* "should send new queries when tab becomes visible" was already skipped) are
* DOM/state assertions with no backend invariant and belong in component/RTL
* coverage, so they are intentionally not migrated here.
*
* The original relied on a seeded tabbed dashboard and expanded the native
* filter bar to change the available width. This migration builds the dashboard
* hermetically (two top-level tabs, a width-sensitive treemap in the first) and
* shrinks the viewport while the treemap is hidden — the equivalent width change,
* with no dependency on seeded data.
*
* CI green => the treemap reflowed to the narrower width and fit its container
* (no horizontal overflow) after the tab switch.
* CI red => the chart kept a stale size and overflowed its container, or the
* width never changed (the reflow was never exercised).
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPost, apiPut } from '../../helpers/api/requests';
import { apiPostDashboard } 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';
const WIDE_VIEWPORT = { width: 1400, height: 900 };
const NARROW_VIEWPORT = { width: 700, height: 900 };
testWithAssets(
'chart in a hidden tab refits its container after the tab is revealed at a new width',
async ({ page, testAssets }) => {
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`;
// Tab A holds a width-sensitive treemap (echarts sizes it to fill the
// container); Tab B holds a table so the second tab has real content.
const chartSpecs = [
{
slug: 'treemap',
params: {
datasource,
viz_type: 'treemap_v2',
metric: 'count',
groupby: ['gender'],
row_limit: 100,
},
},
{
slug: 'table',
params: {
datasource,
viz_type: 'table',
query_mode: 'aggregate',
groupby: ['name'],
metrics: ['count'],
row_limit: 100,
},
},
];
const chartIds: Record<string, number> = {};
for (const { slug, params } of chartSpecs) {
const resp = await apiPost(page, 'api/v1/chart/', {
slice_name: `tabs_${slug}_${Date.now()}`,
viz_type: params.viz_type,
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify(params),
});
expect(resp.ok()).toBe(true);
const body = await resp.json();
const chartId: number = body.id ?? body.result?.id;
testAssets.trackChart(chartId);
chartIds[slug] = chartId;
}
const treemapKey = `CHART-${chartIds.treemap}`;
const tableKey = `CHART-${chartIds.table}`;
// Top-level tabs live inside GRID_ID: ROOT -> GRID -> TABS -> TAB -> ROW -> CHART.
const positionJson: Record<string, unknown> = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['TABS-TOP'],
parents: ['ROOT_ID'],
},
'TABS-TOP': {
type: 'TABS',
id: 'TABS-TOP',
children: ['TAB-A', 'TAB-B'],
parents: ['ROOT_ID', 'GRID_ID'],
meta: {},
},
'TAB-A': {
type: 'TAB',
id: 'TAB-A',
children: ['ROW-A'],
parents: ['ROOT_ID', 'GRID_ID', 'TABS-TOP'],
meta: {
text: 'Tab A',
defaultText: 'Tab title',
placeholder: 'Tab title',
},
},
'TAB-B': {
type: 'TAB',
id: 'TAB-B',
children: ['ROW-B'],
parents: ['ROOT_ID', 'GRID_ID', 'TABS-TOP'],
meta: {
text: 'Tab B',
defaultText: 'Tab title',
placeholder: 'Tab title',
},
},
'ROW-A': {
type: 'ROW',
id: 'ROW-A',
children: [treemapKey],
parents: ['ROOT_ID', 'GRID_ID', 'TABS-TOP', 'TAB-A'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
'ROW-B': {
type: 'ROW',
id: 'ROW-B',
children: [tableKey],
parents: ['ROOT_ID', 'GRID_ID', 'TABS-TOP', 'TAB-B'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
[treemapKey]: {
type: 'CHART',
id: treemapKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'TABS-TOP', 'TAB-A', 'ROW-A'],
meta: {
chartId: chartIds.treemap,
width: 12,
height: 50,
sliceName: 'treemap',
},
},
[tableKey]: {
type: 'CHART',
id: tableKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'TABS-TOP', 'TAB-B', 'ROW-B'],
meta: {
chartId: chartIds.table,
width: 12,
height: 50,
sliceName: 'table',
},
},
};
const dashResp = await apiPostDashboard(page, {
dashboard_title: `tabs_resize_${Date.now()}`,
published: true,
position_json: JSON.stringify(positionJson),
});
expect(dashResp.ok()).toBe(true);
const dashBody = await dashResp.json();
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
testAssets.trackDashboard(dashboardId);
for (const chartId of Object.values(chartIds)) {
await apiPut(page, `api/v1/chart/${chartId}`, {
dashboards: [dashboardId],
});
}
// Render the treemap at the wide viewport first so its initial layout (which
// we measure against) is computed at the wide width.
await page.setViewportSize(WIDE_VIEWPORT);
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
// Tab A is active on load; wait for its treemap to finish rendering.
const treemapContainer = page
.locator('[data-test-viz-type="treemap_v2"]')
.locator('[data-test="chart-container"]');
await treemapContainer.waitFor({
state: 'visible',
timeout: TIMEOUT.API_RESPONSE,
});
await dashboard.waitForChartsToLoad();
const widthsAtWide = await treemapContainer.evaluate((el: HTMLElement) => ({
offsetWidth: el.offsetWidth,
scrollWidth: el.scrollWidth,
}));
// Switch to Tab B (treemap becomes hidden), shrink the viewport so the
// available width changes while the treemap is not visible, then return.
await dashboard.switchToTopLevelTab(1);
await page.setViewportSize(NARROW_VIEWPORT);
await dashboard.switchToTopLevelTab(0);
// Let the reveal settle, mirroring the original's fixed wait.
await treemapContainer.waitFor({
state: 'visible',
timeout: TIMEOUT.API_RESPONSE,
});
await dashboard.waitForChartsToLoad();
// 1) The container itself reflowed to the narrower viewport synchronously on
// reveal. Without this the fit assertion below could pass trivially (a
// chart that never resized still has scrollWidth === offsetWidth), so this
// guards against a false green where the reflow was never exercised.
const offsetWidthAtNarrow = await treemapContainer.evaluate(
(el: HTMLElement) => el.offsetWidth,
);
expect(
offsetWidthAtNarrow,
`treemap container should narrow after the viewport shrank ` +
`(wide=${widthsAtWide.offsetWidth}, narrow=${offsetWidthAtNarrow})`,
).toBeLessThan(widthsAtWide.offsetWidth);
// 2) Once revealed, the treemap refits its container: its rendered content
// fills exactly the available width with no horizontal overflow. The
// echarts canvas re-measures a beat after the tab becomes visible, so we
// poll until it fits. A genuine resize-on-reveal regression leaves the
// chart permanently overflowing (scrollWidth > offsetWidth) and this poll
// times out red; ordinary resize latency converges and it passes.
await expect
.poll(
() =>
treemapContainer.evaluate(
(el: HTMLElement) => el.scrollWidth - el.offsetWidth,
),
{
timeout: TIMEOUT.API_RESPONSE,
message:
'treemap should refit its container (no horizontal overflow) after the tab switch',
},
)
.toBe(0);
},
);