mirror of
https://github.com/apache/superset.git
synced 2026-04-07 10:31:50 +00:00
test(playwright): add dashboard list E2E tests (#38377)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
170
superset-frontend/playwright/helpers/api/dashboard.ts
Normal file
170
superset-frontend/playwright/helpers/api/dashboard.ts
Normal 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;
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
139
superset-frontend/playwright/pages/DashboardListPage.ts
Normal file
139
superset-frontend/playwright/pages/DashboardListPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user