test(playwright): add dashboard list E2E tests (#38377)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-03-04 11:15:16 -08:00
committed by GitHub
parent c25adbc395
commit e2ebc135e4
5 changed files with 809 additions and 1 deletions

View File

@@ -0,0 +1,170 @@
/**
* 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 { Page, APIResponse } from '@playwright/test';
import rison from 'rison';
import {
apiGet,
apiPost,
apiDelete,
apiPut,
ApiRequestOptions,
} from './requests';
export const ENDPOINTS = {
DASHBOARD: 'api/v1/dashboard/',
DASHBOARD_EXPORT: 'api/v1/dashboard/export/',
DASHBOARD_IMPORT: 'api/v1/dashboard/import/',
} as const;
/**
* TypeScript interface for dashboard creation API payload.
* Only dashboard_title is required (DashboardPostSchema).
*/
export interface DashboardCreatePayload {
dashboard_title: string;
slug?: string;
position_json?: string;
css?: string;
json_metadata?: string;
published?: boolean;
}
/**
* POST request to create a dashboard
* @param page - Playwright page instance (provides authentication context)
* @param requestBody - Dashboard configuration object
* @returns API response from dashboard creation
*/
export async function apiPostDashboard(
page: Page,
requestBody: DashboardCreatePayload,
): Promise<APIResponse> {
return apiPost(page, ENDPOINTS.DASHBOARD, requestBody);
}
/**
* GET request to fetch a dashboard's details
* @param page - Playwright page instance (provides authentication context)
* @param dashboardId - ID of the dashboard to fetch
* @param options - Optional request options
* @returns API response with dashboard details
*/
export async function apiGetDashboard(
page: Page,
dashboardId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiGet(page, `${ENDPOINTS.DASHBOARD}${dashboardId}`, options);
}
/**
* DELETE request to remove a dashboard
* @param page - Playwright page instance (provides authentication context)
* @param dashboardId - ID of the dashboard to delete
* @param options - Optional request options
* @returns API response from dashboard deletion
*/
export async function apiDeleteDashboard(
page: Page,
dashboardId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiDelete(page, `${ENDPOINTS.DASHBOARD}${dashboardId}`, options);
}
/**
* PUT request to update a dashboard
* @param page - Playwright page instance (provides authentication context)
* @param dashboardId - ID of the dashboard to update
* @param data - Partial dashboard payload (Marshmallow allows optional fields)
* @param options - Optional request options
* @returns API response from dashboard update
*/
export async function apiPutDashboard(
page: Page,
dashboardId: number,
data: Record<string, unknown>,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiPut(page, `${ENDPOINTS.DASHBOARD}${dashboardId}`, data, options);
}
/**
* Export dashboards as a zip file via the API.
* Uses Rison encoding for the query parameter (required by the endpoint).
* @param page - Playwright page instance (provides authentication context)
* @param dashboardIds - Array of dashboard IDs to export
* @returns API response containing the zip file
*/
export async function apiExportDashboards(
page: Page,
dashboardIds: number[],
): Promise<APIResponse> {
const query = rison.encode(dashboardIds);
return apiGet(page, `${ENDPOINTS.DASHBOARD_EXPORT}?q=${query}`);
}
/**
* TypeScript interface for dashboard search result
*/
export interface DashboardResult {
id: number;
dashboard_title: string;
slug?: string;
published?: boolean;
}
/**
* Get a dashboard by its title
* @param page - Playwright page instance (provides authentication context)
* @param title - The dashboard_title to search for
* @returns Dashboard object if found, null if not found
*/
export async function getDashboardByName(
page: Page,
title: string,
): Promise<DashboardResult | null> {
const filter = {
filters: [
{
col: 'dashboard_title',
opr: 'eq',
value: title,
},
],
};
const queryParam = rison.encode(filter);
const response = await apiGet(
page,
`${ENDPOINTS.DASHBOARD}?q=${queryParam}`,
{ failOnStatusCode: false },
);
if (!response.ok()) {
return null;
}
const body = await response.json();
if (body.result && body.result.length > 0) {
return body.result[0] as DashboardResult;
}
return null;
}

View File

@@ -19,6 +19,7 @@
import { test as base } from '@playwright/test';
import { apiDeleteChart } from '../api/chart';
import { apiDeleteDashboard } from '../api/dashboard';
import { apiDeleteDataset } from '../api/dataset';
import { apiDeleteDatabase } from '../api/database';
@@ -27,6 +28,7 @@ import { apiDeleteDatabase } from '../api/database';
* Inspired by Cypress's cleanDashboards/cleanCharts pattern.
*/
export interface TestAssets {
trackDashboard(id: number): void;
trackChart(id: number): void;
trackDataset(id: number): void;
trackDatabase(id: number): void;
@@ -37,19 +39,39 @@ const EXPECTED_CLEANUP_STATUSES = new Set([200, 202, 204, 404]);
export const test = base.extend<{ testAssets: TestAssets }>({
testAssets: async ({ page }, use) => {
// Use Set to de-dupe IDs (same resource may be tracked multiple times)
const dashboardIds = new Set<number>();
const chartIds = new Set<number>();
const datasetIds = new Set<number>();
const databaseIds = new Set<number>();
await use({
trackDashboard: id => dashboardIds.add(id),
trackChart: id => chartIds.add(id),
trackDataset: id => datasetIds.add(id),
trackDatabase: id => databaseIds.add(id),
});
// Cleanup order: charts → datasets → databases (respects FK dependencies)
// Cleanup order: dashboards → charts → datasets → databases (respects FK dependencies)
// Use failOnStatusCode: false to avoid throwing on 404 (resource already deleted by test)
// Warn on unexpected status codes (401/403/500) that may indicate leaked state
await Promise.all(
[...dashboardIds].map(id =>
apiDeleteDashboard(page, id, { failOnStatusCode: false })
.then(response => {
if (!EXPECTED_CLEANUP_STATUSES.has(response.status())) {
console.warn(
`[testAssets] Unexpected status ${response.status()} cleaning up dashboard ${id}`,
);
}
})
.catch(error => {
console.warn(
`[testAssets] Failed to cleanup dashboard ${id}:`,
error,
);
}),
),
);
await Promise.all(
[...chartIds].map(id =>
apiDeleteChart(page, id, { failOnStatusCode: false })

View File

@@ -0,0 +1,139 @@
/**
* 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 { Page, Locator } from '@playwright/test';
import { Button, Table } from '../components/core';
import { BulkSelect } from '../components/ListView';
import { URL } from '../utils/urls';
/**
* Dashboard List Page object.
*/
export class DashboardListPage {
private readonly page: Page;
private readonly table: Table;
readonly bulkSelect: BulkSelect;
/**
* Action button names for getByRole('button', { name })
* DashboardList uses Icons.DeleteOutlined, Icons.UploadOutlined, Icons.EditOutlined
*/
private static readonly ACTION_BUTTONS = {
DELETE: 'delete',
EDIT: 'edit',
EXPORT: 'upload',
} as const;
constructor(page: Page) {
this.page = page;
this.table = new Table(page);
this.bulkSelect = new BulkSelect(page, this.table);
}
/**
* Navigate to the dashboard list page.
* Forces table view via URL parameter to avoid card view default
* (ListviewsDefaultCardView feature flag may enable card view).
*/
async goto(): Promise<void> {
await this.page.goto(`${URL.DASHBOARD_LIST}?viewMode=table`);
}
/**
* Wait for the table to load
* @param options - Optional wait options
*/
async waitForTableLoad(options?: { timeout?: number }): Promise<void> {
await this.table.waitForVisible(options);
}
/**
* Gets a dashboard row locator by name.
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
*
* @param dashboardName - The name of the dashboard
* @returns Locator for the dashboard row
*/
getDashboardRow(dashboardName: string): Locator {
return this.table.getRow(dashboardName);
}
/**
* Clicks the delete action button for a dashboard
* @param dashboardName - The name of the dashboard to delete
*/
async clickDeleteAction(dashboardName: string): Promise<void> {
const row = this.table.getRow(dashboardName);
await row
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.DELETE })
.click();
}
/**
* Clicks the edit action button for a dashboard
* @param dashboardName - The name of the dashboard to edit
*/
async clickEditAction(dashboardName: string): Promise<void> {
const row = this.table.getRow(dashboardName);
await row
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.EDIT })
.click();
}
/**
* Clicks the export action button for a dashboard
* @param dashboardName - The name of the dashboard to export
*/
async clickExportAction(dashboardName: string): Promise<void> {
const row = this.table.getRow(dashboardName);
await row
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.EXPORT })
.click();
}
/**
* Clicks the "Bulk select" button to enable bulk selection mode
*/
async clickBulkSelectButton(): Promise<void> {
await this.bulkSelect.enable();
}
/**
* Selects a dashboard's checkbox in bulk select mode
* @param dashboardName - The name of the dashboard to select
*/
async selectDashboardCheckbox(dashboardName: string): Promise<void> {
await this.bulkSelect.selectRow(dashboardName);
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* @param actionName - The name of the bulk action to click
*/
async clickBulkAction(actionName: string): Promise<void> {
await this.bulkSelect.clickAction(actionName);
}
/**
* Clicks the import button on the dashboard list page
*/
async clickImportButton(): Promise<void> {
await new Button(this.page, this.page.getByTestId('import-button')).click();
}
}

View File

@@ -0,0 +1,403 @@
/**
* 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 {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { DashboardListPage } from '../../../pages/DashboardListPage';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal';
import { Toast } from '../../../components/core/Toast';
import {
apiGetDashboard,
apiDeleteDashboard,
apiExportDashboards,
getDashboardByName,
ENDPOINTS,
} from '../../../helpers/api/dashboard';
import { createTestDashboard } from './dashboard-test-helpers';
import { waitForGet, waitForPost } from '../../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../../helpers/api/assertions';
import { TIMEOUT } from '../../../utils/constants';
/**
* Extend testWithAssets with dashboardListPage navigation (beforeEach equivalent).
*/
const test = testWithAssets.extend<{ dashboardListPage: DashboardListPage }>({
dashboardListPage: async ({ page }, use) => {
const dashboardListPage = new DashboardListPage(page);
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
await use(dashboardListPage);
},
});
test('should delete a dashboard with confirmation', async ({
page,
dashboardListPage,
testAssets,
}) => {
// Create throwaway dashboard for deletion
const { id: dashboardId, name: dashboardName } = await createTestDashboard(
page,
testAssets,
test.info(),
{ prefix: 'test_delete' },
);
// Refresh to see the new dashboard (created via API)
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify dashboard is visible in list
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
// Click delete action button
await dashboardListPage.clickDeleteAction(dashboardName);
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify dashboard is removed from list
await expect(
dashboardListPage.getDashboardRow(dashboardName),
).not.toBeVisible();
// Backend verification: API returns 404
await expect
.poll(
async () => {
const response = await apiGetDashboard(page, dashboardId, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Dashboard ${dashboardId} should return 404` },
)
.toBe(404);
});
test('should export a dashboard as a zip file', async ({
page,
dashboardListPage,
testAssets,
}) => {
// Create throwaway dashboard for export
const { name: dashboardName } = await createTestDashboard(
page,
testAssets,
test.info(),
{ prefix: 'test_export' },
);
// Refresh to see the new dashboard
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify dashboard is visible in list
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
// Click export action button
await dashboardListPage.clickExportAction(dashboardName);
// Wait for export API response and validate zip contents
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
await expectValidExportZip(exportResponse, {
resourceDir: 'dashboards',
expectedNames: [dashboardName],
});
});
test('should bulk delete multiple dashboards', async ({
page,
dashboardListPage,
testAssets,
}) => {
test.setTimeout(60_000);
// Create 2 throwaway dashboards for bulk delete
const [dashboard1, dashboard2] = await Promise.all([
createTestDashboard(page, testAssets, test.info(), {
prefix: 'bulk_delete_1',
}),
createTestDashboard(page, testAssets, test.info(), {
prefix: 'bulk_delete_2',
}),
]);
// Refresh to see new dashboards
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify both dashboards are visible in list
await expect(
dashboardListPage.getDashboardRow(dashboard1.name),
).toBeVisible();
await expect(
dashboardListPage.getDashboardRow(dashboard2.name),
).toBeVisible();
// Enable bulk select mode
await dashboardListPage.clickBulkSelectButton();
// Select both dashboards
await dashboardListPage.selectDashboardCheckbox(dashboard1.name);
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
// Click bulk delete action
await dashboardListPage.clickBulkAction('Delete');
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both dashboards are removed from list
await expect(
dashboardListPage.getDashboardRow(dashboard1.name),
).not.toBeVisible();
await expect(
dashboardListPage.getDashboardRow(dashboard2.name),
).not.toBeVisible();
// Backend verification: Both return 404
for (const dashboard of [dashboard1, dashboard2]) {
await expect
.poll(
async () => {
const response = await apiGetDashboard(page, dashboard.id, {
failOnStatusCode: false,
});
return response.status();
},
{
timeout: 10000,
message: `Dashboard ${dashboard.id} should return 404`,
},
)
.toBe(404);
}
});
test('should bulk export multiple dashboards', async ({
page,
dashboardListPage,
testAssets,
}) => {
// Create 2 throwaway dashboards for bulk export
const [dashboard1, dashboard2] = await Promise.all([
createTestDashboard(page, testAssets, test.info(), {
prefix: 'bulk_export_1',
}),
createTestDashboard(page, testAssets, test.info(), {
prefix: 'bulk_export_2',
}),
]);
// Refresh to see new dashboards
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify both dashboards are visible in list
await expect(
dashboardListPage.getDashboardRow(dashboard1.name),
).toBeVisible();
await expect(
dashboardListPage.getDashboardRow(dashboard2.name),
).toBeVisible();
// Enable bulk select mode
await dashboardListPage.clickBulkSelectButton();
// Select both dashboards
await dashboardListPage.selectDashboardCheckbox(dashboard1.name);
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
// Click bulk export action
await dashboardListPage.clickBulkAction('Export');
// Wait for export API response and validate zip contains both dashboards
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
await expectValidExportZip(exportResponse, {
resourceDir: 'dashboards',
minCount: 2,
expectedNames: [dashboard1.name, dashboard2.name],
});
});
// Import test uses export-then-reimport approach (no static fixture needed).
// Uses test.describe only because Playwright's serial mode API requires it -
// this prevents race conditions when parallel workers import the same dashboard.
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
test.describe('import dashboard', () => {
test.describe.configure({ mode: 'serial' });
test('should import a dashboard from a zip file', async ({
page,
dashboardListPage,
testAssets,
}) => {
test.setTimeout(60_000);
// Create a dashboard, export it via API, then delete it, then reimport via UI
const { id: dashboardId, name: dashboardName } = await createTestDashboard(
page,
testAssets,
test.info(),
{
prefix: 'test_import',
},
);
// Export the dashboard via API to get a zip buffer
const exportResponse = await apiExportDashboards(page, [dashboardId]);
expect(exportResponse.ok()).toBe(true);
const exportBuffer = await exportResponse.body();
// Delete the dashboard so reimport creates it fresh
await apiDeleteDashboard(page, dashboardId);
// Verify it's gone
await expect
.poll(
async () => {
const response = await apiGetDashboard(page, dashboardId, {
failOnStatusCode: false,
});
return response.status();
},
{
timeout: 10000,
message: `Dashboard ${dashboardId} should return 404 after delete`,
},
)
.toBe(404);
// Refresh to confirm dashboard is no longer in the list
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
await expect(
dashboardListPage.getDashboardRow(dashboardName),
).not.toBeVisible();
// Click the import button
await dashboardListPage.clickImportButton();
// Reuse ImportDatasetModal (same shared ImportModelsModal UI)
const importModal = new ImportDatasetModal(page);
await importModal.waitForReady();
// Upload the exported zip via buffer (no temp file needed)
await page.locator('[data-test="model-file-input"]').setInputFiles({
name: 'dashboard_export.zip',
mimeType: 'application/zip',
buffer: exportBuffer,
});
// Set up response intercept for the import POST
let importResponsePromise = waitForPost(page, ENDPOINTS.DASHBOARD_IMPORT, {
pathMatch: true,
});
// Click Import button
await importModal.clickImport();
// Wait for first import response
let importResponse = await importResponsePromise;
// Handle overwrite confirmation if dashboard already exists
const overwriteInput = importModal.getOverwriteInput();
await overwriteInput
.waitFor({ state: 'visible', timeout: 3000 })
.catch(error => {
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
});
if (await overwriteInput.isVisible()) {
importResponsePromise = waitForPost(page, ENDPOINTS.DASHBOARD_IMPORT, {
pathMatch: true,
});
await importModal.fillOverwriteConfirmation();
await importModal.clickImport();
importResponse = await importResponsePromise;
}
// Verify import succeeded
expectStatusOneOf(importResponse, [200]);
// Modal should close on success
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
// Refresh to see the imported dashboard
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify dashboard appears in list
await expect(
dashboardListPage.getDashboardRow(dashboardName),
).toBeVisible();
// Track for cleanup: look up the reimported dashboard by title
const reimported = await getDashboardByName(page, dashboardName);
if (reimported) {
testAssets.trackDashboard(reimported.id);
}
});
});

View File

@@ -0,0 +1,74 @@
/**
* 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 type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { apiPostDashboard } from '../../../helpers/api/dashboard';
interface TestDashboardResult {
id: number;
name: string;
}
interface CreateTestDashboardOptions {
/** Prefix for generated name (default: 'test_dashboard') */
prefix?: string;
}
/**
* Creates a test dashboard via the API for E2E testing.
*
* @example
* const { id, name } = await createTestDashboard(page, testAssets, test.info());
*
* @example
* const { id, name } = await createTestDashboard(page, testAssets, test.info(), {
* prefix: 'test_delete',
* });
*/
export async function createTestDashboard(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
options?: CreateTestDashboardOptions,
): Promise<TestDashboardResult> {
const prefix = options?.prefix ?? 'test_dashboard';
const name = `${prefix}_${Date.now()}_${testInfo.parallelIndex}`;
const response = await apiPostDashboard(page, {
dashboard_title: name,
});
if (!response.ok()) {
throw new Error(`Failed to create test dashboard: ${response.status()}`);
}
const body = await response.json();
// Handle both response shapes: { id } or { result: { id } }
const id = body.result?.id ?? body.id;
if (!id) {
throw new Error(
`Dashboard creation returned no id. Response: ${JSON.stringify(body)}`,
);
}
testAssets.trackDashboard(id);
return { id, name };
}