This is an automated email from the ASF dual-hosted git repository. jli pushed a commit to branch feat-chart-list-to-playwright in repository https://gitbox.apache.org/repos/asf/superset.git
commit 47138b7c4aef26142bd458725d7445633d9b092f Author: Joe Li <[email protected]> AuthorDate: Tue Feb 10 14:53:05 2026 -0800 test(e2e): add Playwright E2E tests for Chart List page Migrate Chart List E2E tests from Cypress to Playwright, covering delete, edit, export, bulk delete, and bulk export operations with backend API verification. New files: - Chart API helpers (CRUD + getByName) - ChartListPage page object (Table + BulkSelect composition) - ChartPropertiesModal component (extends Modal base) - Chart test helpers (createTestChart factory) - 5 E2E test cases in chart-list.spec.ts Modified: - testAssets fixture: added trackChart() with chart cleanup Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../components/modals/ChartPropertiesModal.ts | 55 ++++ superset-frontend/playwright/helpers/api/chart.ts | 141 +++++++++ .../playwright/helpers/fixtures/testAssets.ts | 15 +- .../playwright/pages/ChartListPage.ts | 132 +++++++++ .../tests/experimental/chart/chart-list.spec.ts | 322 +++++++++++++++++++++ .../tests/experimental/chart/chart-test-helpers.ts | 88 ++++++ 6 files changed, 751 insertions(+), 2 deletions(-) diff --git a/superset-frontend/playwright/components/modals/ChartPropertiesModal.ts b/superset-frontend/playwright/components/modals/ChartPropertiesModal.ts new file mode 100644 index 00000000000..5b90f493271 --- /dev/null +++ b/superset-frontend/playwright/components/modals/ChartPropertiesModal.ts @@ -0,0 +1,55 @@ +/** + * 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 } from '@playwright/test'; +import { Modal } from '../core'; + +/** + * Chart properties edit modal. + * Opened by clicking the edit icon on a chart row in the chart list. + * General section is expanded by default (defaultActiveKey="general"). + */ +export class ChartPropertiesModal extends Modal { + private static readonly SELECTORS = { + NAME_INPUT: '[data-test="properties-modal-name-input"]', + }; + + constructor(page: Page) { + super(page, '[data-test="properties-edit-modal"]'); + } + + /** + * Fills the chart name input field + * @param name - The new chart name + */ + async fillName(name: string): Promise<void> { + const input = this.body.locator( + ChartPropertiesModal.SELECTORS.NAME_INPUT, + ); + await input.clear(); + await input.fill(name); + } + + /** + * Clicks the Save button in the modal footer + */ + async clickSave(): Promise<void> { + await this.clickFooterButton('Save'); + } +} diff --git a/superset-frontend/playwright/helpers/api/chart.ts b/superset-frontend/playwright/helpers/api/chart.ts new file mode 100644 index 00000000000..abb25e1464f --- /dev/null +++ b/superset-frontend/playwright/helpers/api/chart.ts @@ -0,0 +1,141 @@ +/** + * 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 = { + CHART: 'api/v1/chart/', + CHART_EXPORT: 'api/v1/chart/export/', +} as const; + +/** + * TypeScript interface for chart creation API payload. + * Only slice_name, datasource_id, datasource_type are required (ChartPostSchema). + */ +export interface ChartCreatePayload { + slice_name: string; + datasource_id: number; + datasource_type: string; + viz_type?: string; + params?: string; +} + +/** + * POST request to create a chart + * @param page - Playwright page instance (provides authentication context) + * @param requestBody - Chart configuration object + * @returns API response from chart creation + */ +export async function apiPostChart( + page: Page, + requestBody: ChartCreatePayload, +): Promise<APIResponse> { + return apiPost(page, ENDPOINTS.CHART, requestBody); +} + +/** + * GET request to fetch a chart's details + * @param page - Playwright page instance (provides authentication context) + * @param chartId - ID of the chart to fetch + * @param options - Optional request options + * @returns API response with chart details + */ +export async function apiGetChart( + page: Page, + chartId: number, + options?: ApiRequestOptions, +): Promise<APIResponse> { + return apiGet(page, `${ENDPOINTS.CHART}${chartId}`, options); +} + +/** + * DELETE request to remove a chart + * @param page - Playwright page instance (provides authentication context) + * @param chartId - ID of the chart to delete + * @param options - Optional request options + * @returns API response from chart deletion + */ +export async function apiDeleteChart( + page: Page, + chartId: number, + options?: ApiRequestOptions, +): Promise<APIResponse> { + return apiDelete(page, `${ENDPOINTS.CHART}${chartId}`, options); +} + +/** + * PUT request to update a chart + * @param page - Playwright page instance (provides authentication context) + * @param chartId - ID of the chart to update + * @param data - Partial chart payload (Marshmallow allows optional fields) + * @param options - Optional request options + * @returns API response from chart update + */ +export async function apiPutChart( + page: Page, + chartId: number, + data: Record<string, unknown>, + options?: ApiRequestOptions, +): Promise<APIResponse> { + return apiPut(page, `${ENDPOINTS.CHART}${chartId}`, data, options); +} + +/** + * Get a chart by its slice_name + * @param page - Playwright page instance (provides authentication context) + * @param sliceName - The slice_name to search for + * @returns Chart object if found, null if not found + */ +export async function getChartByName( + page: Page, + sliceName: string, +): Promise<{ id: number; slice_name: string } | null> { + const filter = { + filters: [ + { + col: 'slice_name', + opr: 'eq', + value: sliceName, + }, + ], + }; + const queryParam = rison.encode(filter); + const response = await apiGet(page, `${ENDPOINTS.CHART}?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]; + } + + return null; +} diff --git a/superset-frontend/playwright/helpers/fixtures/testAssets.ts b/superset-frontend/playwright/helpers/fixtures/testAssets.ts index 16f104f3704..c793466a726 100644 --- a/superset-frontend/playwright/helpers/fixtures/testAssets.ts +++ b/superset-frontend/playwright/helpers/fixtures/testAssets.ts @@ -18,6 +18,7 @@ */ import { test as base } from '@playwright/test'; +import { apiDeleteChart } from '../api/chart'; import { apiDeleteDataset } from '../api/dataset'; import { apiDeleteDatabase } from '../api/database'; @@ -26,6 +27,7 @@ import { apiDeleteDatabase } from '../api/database'; * Inspired by Cypress's cleanDashboards/cleanCharts pattern. */ export interface TestAssets { + trackChart(id: number): void; trackDataset(id: number): void; trackDatabase(id: number): void; } @@ -33,16 +35,25 @@ export interface TestAssets { 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 chartIds = new Set<number>(); const datasetIds = new Set<number>(); const databaseIds = new Set<number>(); await use({ + trackChart: id => chartIds.add(id), trackDataset: id => datasetIds.add(id), trackDatabase: id => databaseIds.add(id), }); - // Cleanup: Delete datasets FIRST (they reference databases) - // Then delete databases. Use failOnStatusCode: false for tolerance. + // Cleanup order: charts → datasets → databases (respects FK dependencies) + // Use failOnStatusCode: false + .catch() to tolerate 404s (resources deleted by tests) + await Promise.all( + [...chartIds].map(id => + apiDeleteChart(page, id, { failOnStatusCode: false }).catch(error => { + console.warn(`[testAssets] Failed to cleanup chart ${id}:`, error); + }), + ), + ); await Promise.all( [...datasetIds].map(id => apiDeleteDataset(page, id, { failOnStatusCode: false }).catch(error => { diff --git a/superset-frontend/playwright/pages/ChartListPage.ts b/superset-frontend/playwright/pages/ChartListPage.ts new file mode 100644 index 00000000000..74f75181d24 --- /dev/null +++ b/superset-frontend/playwright/pages/ChartListPage.ts @@ -0,0 +1,132 @@ +/** + * 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 { Table } from '../components/core'; +import { BulkSelect } from '../components/ListView'; +import { URL } from '../utils/urls'; + +/** + * Chart List Page object. + */ +export class ChartListPage { + private readonly page: Page; + private readonly table: Table; + readonly bulkSelect: BulkSelect; + + /** + * Action button names for getByRole('button', { name }) + * Verified: ChartList 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 chart 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.CHART_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 chart row locator by name. + * Returns a Locator that tests can use with expect().toBeVisible(), etc. + * + * @param chartName - The name of the chart + * @returns Locator for the chart row + */ + getChartRow(chartName: string): Locator { + return this.table.getRow(chartName); + } + + /** + * Clicks the delete action button for a chart + * @param chartName - The name of the chart to delete + */ + async clickDeleteAction(chartName: string): Promise<void> { + const row = this.table.getRow(chartName); + await row + .getByRole('button', { name: ChartListPage.ACTION_BUTTONS.DELETE }) + .click(); + } + + /** + * Clicks the edit action button for a chart + * @param chartName - The name of the chart to edit + */ + async clickEditAction(chartName: string): Promise<void> { + const row = this.table.getRow(chartName); + await row + .getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EDIT }) + .click(); + } + + /** + * Clicks the export action button for a chart + * @param chartName - The name of the chart to export + */ + async clickExportAction(chartName: string): Promise<void> { + const row = this.table.getRow(chartName); + await row + .getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EXPORT }) + .click(); + } + + /** + * Clicks the "Bulk select" button to enable bulk selection mode + */ + async clickBulkSelectButton(): Promise<void> { + await this.bulkSelect.enable(); + } + + /** + * Selects a chart's checkbox in bulk select mode + * @param chartName - The name of the chart to select + */ + async selectChartCheckbox(chartName: string): Promise<void> { + await this.bulkSelect.selectRow(chartName); + } + + /** + * 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); + } +} diff --git a/superset-frontend/playwright/tests/experimental/chart/chart-list.spec.ts b/superset-frontend/playwright/tests/experimental/chart/chart-list.spec.ts new file mode 100644 index 00000000000..ea37716fd95 --- /dev/null +++ b/superset-frontend/playwright/tests/experimental/chart/chart-list.spec.ts @@ -0,0 +1,322 @@ +/** + * 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 type { Response } from '@playwright/test'; +import * as unzipper from 'unzipper'; +import { ChartListPage } from '../../../pages/ChartListPage'; +import { ChartPropertiesModal } from '../../../components/modals/ChartPropertiesModal'; +import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal'; +import { Toast } from '../../../components/core/Toast'; +import { apiGetChart, ENDPOINTS } from '../../../helpers/api/chart'; +import { createTestChart } from './chart-test-helpers'; +import { waitForGet } from '../../../helpers/api/intercepts'; +import { expectStatusOneOf } from '../../../helpers/api/assertions'; + +/** + * Extend testWithAssets with chartListPage navigation (beforeEach equivalent). + */ +const test = testWithAssets.extend<{ chartListPage: ChartListPage }>({ + chartListPage: async ({ page }, use) => { + const chartListPage = new ChartListPage(page); + await chartListPage.goto(); + await chartListPage.waitForTableLoad(); + await use(chartListPage); + }, +}); + +/** + * Helper to validate an export zip response for charts. + * Verifies headers, parses zip contents, and validates expected structure. + */ +async function expectValidChartExportZip( + response: Response, + options: { minChartCount?: number } = {}, +): Promise<void> { + const { minChartCount = 1 } = options; + + // Verify content type (use toContain to handle charset suffixes) + expect(response.headers()['content-type']).toContain('application/zip'); + + // Parse and validate zip contents + const body = await response.body(); + expect(body.length).toBeGreaterThan(0); + + const entries: string[] = []; + const directory = await unzipper.Open.buffer(body); + directory.files.forEach(file => entries.push(file.path)); + + // Validate structure: charts dir with yaml files + metadata + const chartYamlFiles = entries.filter( + entry => entry.includes('charts/') && entry.endsWith('.yaml'), + ); + expect(chartYamlFiles.length).toBeGreaterThanOrEqual(minChartCount); + expect(entries.some(entry => entry.endsWith('metadata.yaml'))).toBe(true); +} + +test('should delete a chart with confirmation', async ({ + page, + chartListPage, + testAssets, +}) => { + // Create throwaway chart for deletion + const { id: chartId, name: chartName } = await createTestChart( + page, + testAssets, + test.info(), + { prefix: 'test_delete' }, + ); + + // Refresh to see the new chart (created via API) + await chartListPage.goto(); + await chartListPage.waitForTableLoad(); + + // Verify chart is visible in list + await expect(chartListPage.getChartRow(chartName)).toBeVisible(); + + // Click delete action button + await chartListPage.clickDeleteAction(chartName); + + // 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 chart is removed from list + await expect(chartListPage.getChartRow(chartName)).not.toBeVisible(); + + // Backend verification: API returns 404 + await expect + .poll( + async () => { + const response = await apiGetChart(page, chartId, { + failOnStatusCode: false, + }); + return response.status(); + }, + { timeout: 10000, message: `Chart ${chartId} should return 404` }, + ) + .toBe(404); +}); + +test('should edit chart name via properties modal', async ({ + page, + chartListPage, + testAssets, +}) => { + // Create throwaway chart for editing + const { id: chartId, name: chartName } = await createTestChart( + page, + testAssets, + test.info(), + { prefix: 'test_edit' }, + ); + + // Refresh to see the new chart + await chartListPage.goto(); + await chartListPage.waitForTableLoad(); + + // Verify chart is visible in list + await expect(chartListPage.getChartRow(chartName)).toBeVisible(); + + // Click edit action to open properties modal + await chartListPage.clickEditAction(chartName); + + // Wait for properties modal to be ready + const propertiesModal = new ChartPropertiesModal(page); + await propertiesModal.waitForReady(); + + // Edit the chart name + const newName = `renamed_${Date.now()}_${test.info().parallelIndex}`; + await propertiesModal.fillName(newName); + + // Click Save button + await propertiesModal.clickSave(); + + // Modal should close + await propertiesModal.waitForHidden(); + + // Verify success toast appears + const toast = new Toast(page); + await expect(toast.getSuccess()).toBeVisible(); + + // Backend verification: API returns updated name + const response = await apiGetChart(page, chartId); + const chart = (await response.json()).result; + expect(chart.slice_name).toBe(newName); +}); + +test('should export a chart as a zip file', async ({ + page, + chartListPage, + testAssets, +}) => { + // Create throwaway chart for export + const { name: chartName } = await createTestChart( + page, + testAssets, + test.info(), + { prefix: 'test_export' }, + ); + + // Refresh to see the new chart + await chartListPage.goto(); + await chartListPage.waitForTableLoad(); + + // Verify chart is visible in list + await expect(chartListPage.getChartRow(chartName)).toBeVisible(); + + // Set up API response intercept for export endpoint + const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT); + + // Click export action button + await chartListPage.clickExportAction(chartName); + + // Wait for export API response and validate zip contents + const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]); + await expectValidChartExportZip(exportResponse); +}); + +test('should bulk delete multiple charts', async ({ + page, + chartListPage, + testAssets, +}) => { + test.setTimeout(60_000); + + // Create 2 throwaway charts for bulk delete + const [chart1, chart2] = await Promise.all([ + createTestChart(page, testAssets, test.info(), { + prefix: 'bulk_delete_1', + }), + createTestChart(page, testAssets, test.info(), { + prefix: 'bulk_delete_2', + }), + ]); + + // Refresh to see new charts + await chartListPage.goto(); + await chartListPage.waitForTableLoad(); + + // Verify both charts are visible in list + await expect(chartListPage.getChartRow(chart1.name)).toBeVisible(); + await expect(chartListPage.getChartRow(chart2.name)).toBeVisible(); + + // Enable bulk select mode + await chartListPage.clickBulkSelectButton(); + + // Select both charts + await chartListPage.selectChartCheckbox(chart1.name); + await chartListPage.selectChartCheckbox(chart2.name); + + // Click bulk delete action + await chartListPage.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 charts are removed from list + await expect(chartListPage.getChartRow(chart1.name)).not.toBeVisible(); + await expect(chartListPage.getChartRow(chart2.name)).not.toBeVisible(); + + // Backend verification: Both return 404 + for (const chart of [chart1, chart2]) { + await expect + .poll( + async () => { + const response = await apiGetChart(page, chart.id, { + failOnStatusCode: false, + }); + return response.status(); + }, + { timeout: 10000, message: `Chart ${chart.id} should return 404` }, + ) + .toBe(404); + } +}); + +test('should bulk export multiple charts', async ({ + page, + chartListPage, + testAssets, +}) => { + // Create 2 throwaway charts for bulk export + const [chart1, chart2] = await Promise.all([ + createTestChart(page, testAssets, test.info(), { + prefix: 'bulk_export_1', + }), + createTestChart(page, testAssets, test.info(), { + prefix: 'bulk_export_2', + }), + ]); + + // Refresh to see new charts + await chartListPage.goto(); + await chartListPage.waitForTableLoad(); + + // Verify both charts are visible in list + await expect(chartListPage.getChartRow(chart1.name)).toBeVisible(); + await expect(chartListPage.getChartRow(chart2.name)).toBeVisible(); + + // Enable bulk select mode + await chartListPage.clickBulkSelectButton(); + + // Select both charts + await chartListPage.selectChartCheckbox(chart1.name); + await chartListPage.selectChartCheckbox(chart2.name); + + // Set up API response intercept for export endpoint + const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT); + + // Click bulk export action + await chartListPage.clickBulkAction('Export'); + + // Wait for export API response and validate zip contains multiple charts + const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]); + await expectValidChartExportZip(exportResponse, { minChartCount: 2 }); +}); diff --git a/superset-frontend/playwright/tests/experimental/chart/chart-test-helpers.ts b/superset-frontend/playwright/tests/experimental/chart/chart-test-helpers.ts new file mode 100644 index 00000000000..2c1de7c7d28 --- /dev/null +++ b/superset-frontend/playwright/tests/experimental/chart/chart-test-helpers.ts @@ -0,0 +1,88 @@ +/** + * 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 { apiPostChart } from '../../../helpers/api/chart'; +import { getDatasetByName } from '../../../helpers/api/dataset'; + +interface TestChartResult { + id: number; + name: string; +} + +interface CreateTestChartOptions { + /** Prefix for generated name (default: 'test_chart') */ + prefix?: string; +} + +/** + * Creates a test chart via the API for E2E testing. + * Uses the members_channels_2 dataset (loaded via --load-examples). + * + * @example + * const { id, name } = await createTestChart(page, testAssets, test.info()); + * + * @example + * const { id, name } = await createTestChart(page, testAssets, test.info(), { + * prefix: 'test_delete', + * }); + */ +export async function createTestChart( + page: Page, + testAssets: TestAssets, + testInfo: TestInfo, + options?: CreateTestChartOptions, +): Promise<TestChartResult> { + const prefix = options?.prefix ?? 'test_chart'; + const name = `${prefix}_${Date.now()}_${testInfo.parallelIndex}`; + + // Look up the members_channels_2 dataset for chart creation + const dataset = await getDatasetByName(page, 'members_channels_2'); + if (!dataset) { + throw new Error( + 'members_channels_2 dataset not found — run Superset with --load-examples', + ); + } + + const response = await apiPostChart(page, { + slice_name: name, + datasource_id: dataset.id, + datasource_type: 'table', + viz_type: 'table', + params: '{}', + }); + + if (!response.ok()) { + throw new Error(`Failed to create test chart: ${response.status()}`); + } + + const body = await response.json(); + // Chart POST returns { id: number, result: <payload> } — ID is at top level + const id = body.result?.id ?? body.id; + if (!id) { + throw new Error( + `Chart creation returned no id. Response: ${JSON.stringify(body)}`, + ); + } + + testAssets.trackChart(id); + + return { id, name }; +}
