Compare commits

...

5 Commits

Author SHA1 Message Date
Joe Li
7f2592e070 fix(e2e): tighten filter bar readiness 2026-07-24 22:27:23 -07:00
Joe Li
b29c35310b fix(e2e): avoid duplicate timeout import in merge 2026-07-24 15:33:10 -07:00
Joe Li
8d15c67e85 fix(e2e): address filter bar review feedback 2026-07-24 14:57:22 -07:00
Joe Li
8785ac794f test(e2e): harden slow page loads 2026-07-14 15:25:47 -07:00
Joe Li
0a200e6114 refactor(e2e): model dashboard filter bar 2026-07-13 17:17:10 -07:00
9 changed files with 181 additions and 56 deletions

View File

@@ -0,0 +1,122 @@
/**
* 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';
import { Button, Select } from '../core';
import { NativeFiltersConfigModal } from '../modals';
/**
* Dashboard native-filter bar component.
*/
export class DashboardFilterBar {
private static readonly SELECTORS = {
ROOT: '[data-test="filter-bar"]',
FILTER_VALUE: '[data-test="form-item-value"]',
APPLY_BUTTON: '[data-test="filter-bar__apply-button"]',
CLEAR_BUTTON: '[data-test="filter-bar__clear-button"]',
SETTINGS_BUTTON: '[data-test="filterbar-orientation-icon"]',
} as const;
constructor(private readonly page: Page) {}
/**
* Waits for the filter bar controls to become interactive.
*/
async waitForReady(options?: { timeout?: number }): Promise<void> {
await this.getApplyButton().element.waitFor({
state: 'visible',
...options,
});
}
/**
* Selects an option in a native filter.
* @param optionText - The option text to select.
* @param index - The zero-based position of the filter.
*/
async selectOption(optionText: string, index = 0): Promise<void> {
const select = new Select(
this.page,
this.root
.locator(DashboardFilterBar.SELECTORS.FILTER_VALUE)
.nth(index)
.getByRole('combobox'),
);
await select.open();
await select.clickOption(optionText);
await select.close();
}
/**
* Applies pending native-filter changes.
*/
async apply(): Promise<void> {
await this.getApplyButton().click();
}
/**
* Applies pending native-filter changes when the Apply button is enabled.
*/
async applyIfEnabled(): Promise<void> {
const applyButton = this.getApplyButton();
await applyButton.element.waitFor({ state: 'visible' });
if (await applyButton.isDisabled()) {
return;
}
await applyButton.click();
}
/**
* Clears all native-filter values without applying the pending changes.
*/
async clearAll(): Promise<void> {
await new Button(
this.page,
this.root.locator(DashboardFilterBar.SELECTORS.CLEAR_BUTTON),
).click();
}
/**
* Opens the native filters and Display Controls configuration modal.
*/
async openNativeFiltersConfigModal(): Promise<NativeFiltersConfigModal> {
await this.root
.locator(DashboardFilterBar.SELECTORS.SETTINGS_BUTTON)
.click();
await this.page
.getByText('Add or edit filters and controls', { exact: true })
.click();
const modal = new NativeFiltersConfigModal(this.page);
await modal.waitForVisible();
return modal;
}
private getApplyButton(): Button {
return new Button(
this.page,
this.root.locator(DashboardFilterBar.SELECTORS.APPLY_BUTTON),
);
}
private get root(): Locator {
return this.page.locator(DashboardFilterBar.SELECTORS.ROOT);
}
}

View File

@@ -0,0 +1,20 @@
/**
* 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.
*/
export { DashboardFilterBar } from './DashboardFilterBar';

View File

@@ -19,7 +19,7 @@
import { Page, Download, Locator } from '@playwright/test';
import { Menu } from '../components/core';
import { NativeFiltersConfigModal } from '../components/modals';
import { DashboardFilterBar } from '../components/dashboard';
import { gotoWithRetry } from '../helpers/navigation';
import { TIMEOUT } from '../utils/constants';
@@ -28,19 +28,18 @@ import { TIMEOUT } from '../utils/constants';
*/
export class DashboardPage {
private readonly page: Page;
private readonly filterBar: DashboardFilterBar;
private static readonly SELECTORS = {
DASHBOARD_HEADER: '[data-test="dashboard-header-container"]',
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"]',
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"]',
} as const;
constructor(page: Page) {
this.page = page;
this.filterBar = new DashboardFilterBar(page);
}
/**
@@ -117,31 +116,11 @@ export class DashboardPage {
}
/**
* Opens the native filters and Display Controls configuration modal.
* Waits for and returns the dashboard native-filter bar component.
*/
async openNativeFiltersConfigModal(): Promise<NativeFiltersConfigModal> {
await this.page.click(DashboardPage.SELECTORS.FILTER_BAR_SETTINGS);
await this.page
.getByText('Add or edit filters and controls', { exact: true })
.click();
const modal = new NativeFiltersConfigModal(this.page);
await modal.waitForVisible();
return modal;
}
/**
* Applies pending native filter changes when the Apply button is enabled.
*/
async applyFiltersIfEnabled(): Promise<void> {
const applyButton = this.page
.locator(DashboardPage.SELECTORS.APPLY_FILTERS_BUTTON)
.first();
if (!(await applyButton.isEnabled().catch(() => false))) {
return;
}
await applyButton.click();
async waitForFilterBar(): Promise<DashboardFilterBar> {
await this.filterBar.waitForReady();
return this.filterBar;
}
/**

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import type { Request } from '@playwright/test';
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPost, apiPut } from '../../helpers/api/requests';
import { apiPostDashboard } from '../../helpers/api/dashboard';
@@ -148,23 +149,9 @@ testWithAssets(
await dashboardPage.gotoById(dashboardId);
await dashboardPage.waitForLoad({ timeout: TIMEOUT.SLOW_TEST });
await dashboardPage.waitForChartsToLoad();
const filterBar = await dashboardPage.waitForFilterBar();
// The Gender select should be visible in the filter bar
const filterCombobox = page
.locator('[data-test="form-item-value"]')
.first()
.locator('[role="combobox"]');
await filterCombobox.click();
await page
.locator('.ant-select-item-option', { hasText: /^boy$/ })
.first()
.click();
// Close the dropdown
await page.keyboard.press('Escape');
const applyBtn = page.locator(
'[data-test="filter-bar__apply-button"], [data-test="filterbar-action-buttons"] button[type="submit"]',
);
await filterBar.selectOption('boy');
// Wait for chart data to come back after Apply
const firstApplyResponse = page.waitForResponse(
@@ -173,21 +160,20 @@ testWithAssets(
r.request().method() === 'POST',
{ timeout: 10_000 },
);
await applyBtn.first().click();
await filterBar.apply();
await firstApplyResponse;
await dashboardPage.waitForChartsToLoad();
// Now track POST /api/v1/chart/data requests around Clear All
const postsAfterClearAll: string[] = [];
const handler = (req: any) => {
const handler = (req: Request) => {
if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') {
postsAfterClearAll.push(req.url());
}
};
page.on('request', handler);
const clearBtn = page.locator('[data-test="filter-bar__clear-button"]');
await clearBtn.click();
await filterBar.clearAll();
// Allow time for any debounced reload to fire if the bug is present
await page.waitForTimeout(2000);
@@ -209,7 +195,7 @@ testWithAssets(
r.request().method() === 'POST',
{ timeout: 10_000 },
);
await applyBtn.first().click();
await filterBar.apply();
await applyAfterClearPromise;
},
);

View File

@@ -175,6 +175,7 @@ testWithAssets(
await dashboardPage.gotoById(dashboardId);
await dashboardPage.waitForLoad({ timeout: 30000 });
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
const filterBar = await dashboardPage.waitForFilterBar();
// Both the Gender filter and the Time grain Display Control should render.
await expect(dashboardPage.getDisplayControlsHeader()).toBeVisible();
@@ -184,7 +185,7 @@ testWithAssets(
await shot('01-initial-bar');
// 4. Open the filters config modal via the settings gear.
const modal = await dashboardPage.openNativeFiltersConfigModal();
const modal = await filterBar.openNativeFiltersConfigModal();
await shot('02-modal-open');
// 5. Delete the "Time grain" Display Control in the modal sidebar.
@@ -210,7 +211,7 @@ testWithAssets(
);
// 7. Click Apply Filters.
await dashboardPage.applyFiltersIfEnabled();
await filterBar.applyIfEnabled();
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
await shot('05-after-apply');

View File

@@ -39,8 +39,8 @@ const downloads: { delete: () => Promise<void> }[] = [];
test.describe('Dashboard Export', () => {
// Dashboard with multiple charts needs extra time for cold-cache CI runs:
// waitForLoad (10s) + waitForChartsToLoad (15s) + menu + download + toast
test.setTimeout(60_000);
// waitForLoad (10s) + waitForChartsToLoad (30s) + menu + download + toast
test.setTimeout(90_000);
test.beforeEach(async ({ page }) => {
dashboardPage = new DashboardPage(page);
@@ -49,7 +49,7 @@ test.describe('Dashboard Export', () => {
await dashboardPage.gotoBySlug('world_health');
await dashboardPage.waitForLoad({ timeout: TIMEOUT.PAGE_LOAD });
// Wait for charts to finish loading - Download menu may be disabled while loading
await dashboardPage.waitForChartsToLoad();
await dashboardPage.waitForChartsToLoad({ timeout: TIMEOUT.CHART_RENDER });
});
test.afterEach(async () => {

View File

@@ -38,6 +38,7 @@ import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
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';
@@ -51,6 +52,8 @@ const COLOR_UNUSED_3: [number, number, number] = [90, 193, 137];
testWithAssets(
'Gauge renders configured interval colors on a dashboard (#28766)',
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`);

View File

@@ -79,7 +79,7 @@ test('should navigate to Explore when dataset name is clicked', async ({
await datasetListPage.clickDatasetName(datasetName);
// Wait for Explore page to load (validates URL + datasource control)
await explorePage.waitForPageLoad();
await explorePage.waitForPageLoad({ timeout: TIMEOUT.EXPLORE_PAGE_LOAD });
// Verify correct dataset is loaded in datasource control
const loadedDatasetName = await explorePage.getDatasetName();

View File

@@ -39,6 +39,11 @@ export const TIMEOUT = {
*/
PAGE_LOAD: 10000, // 10s for page transitions (login → welcome, dataset → explore)
/**
* Dataset-to-Explore navigation on cold CI runners
*/
EXPLORE_PAGE_LOAD: 15000, // 15s for Explore to load its datasource control
/**
* Form and UI element load timeouts
*/
@@ -72,6 +77,15 @@ export const TIMEOUT = {
*/
QUERY_EXECUTION: 30000, // 30s for SQL queries
/**
* A chart going from mounted to painted with real data.
* Navigating to a dashboard only waits for its header, so the first chart
* asserted absorbs the whole uncached query round-trip — more than the global
* expect timeout allows on a loaded runner. Charts query in parallel, so a
* dashboard full of them pays this roughly once.
*/
CHART_RENDER: 30000, // 30s for a chart to query and paint
/**
* Extended test timeout for multi-step tests (page load + query execution + assertions).
* Use with test.setTimeout() when the default 30s test timeout is insufficient.
@@ -89,5 +103,5 @@ export const EMBEDDED = {
/** Timeout for dashboard content to render inside the iframe */
DASHBOARD_RENDER: 30000, // 30s
/** Timeout for individual chart cells to finish rendering */
CHART_RENDER: 30000, // 30s
CHART_RENDER: TIMEOUT.CHART_RENDER,
} as const;