You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2025/11/15 18:22:18 UTC
(superset) 07/18: feat(playwright): implement dataset delete E2E test with API infrastructure
This is an automated email from the ASF dual-hosted git repository.
jli pushed a commit to branch feat/dataset-playwright-tests
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 7a678c925e2728d98899428530ed9e41eee6bded
Author: Joe Li <jo...@preset.io>
AuthorDate: Wed Nov 5 16:57:26 2025 -0800
feat(playwright): implement dataset delete E2E test with API infrastructure
Add comprehensive API infrastructure and delete dataset E2E test:
**API Infrastructure:**
- Generic request helpers with CSRF token handling (requests.ts)
- DRY header building for all mutation requests
- Database and dataset CRUD operations
- Google Sheets database factory for test data isolation
- Response validation with clear error messages
**Toast Component:**
- Reusable toast verification component
- Type-specific getters (success, danger, warning, info)
- SELECTORS constants (no magic strings)
**Delete Dataset Test:**
- Creates test dataset via API (Google Sheets)
- Tests full delete flow: click → modal → confirm → verify removal
- Validates success toast appears with correct message
- Clean afterEach cleanup pattern (no race conditions)
**Code Quality:**
- No private Playwright APIs (uses env variables)
- Path prefix support for prefixed deployments
- All selectors in constants
- Type-safe throughout
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <no...@anthropic.com>
---
.../playwright/components/core/Table.ts | 10 +
.../playwright/components/core/Toast.ts | 105 ++++++++
superset-frontend/playwright/helpers/api/client.ts | 154 ------------
.../playwright/helpers/api/database.factories.ts | 81 ++++++
.../playwright/helpers/api/database.ts | 52 ++++
.../playwright/helpers/api/dataset.factories.ts | 64 +++++
.../playwright/helpers/api/dataset.ts | 52 ++++
.../playwright/helpers/api/datasets.ts | 272 ---------------------
.../playwright/helpers/api/requests.ts | 178 ++++++++++++++
.../playwright/pages/DatasetListPage.ts | 36 +++
.../playwright/tests/dataset/dataset-list.spec.ts | 77 +++++-
11 files changed, 654 insertions(+), 427 deletions(-)
diff --git a/superset-frontend/playwright/components/core/Table.ts b/superset-frontend/playwright/components/core/Table.ts
index aa8ae40e52..84dd138b90 100644
--- a/superset-frontend/playwright/components/core/Table.ts
+++ b/superset-frontend/playwright/components/core/Table.ts
@@ -69,4 +69,14 @@ export class Table {
async waitForVisible(options?: { timeout?: number }): Promise<void> {
await this.element.waitFor({ state: 'visible', ...options });
}
+
+ /**
+ * Clicks an action button in a row by selector
+ * @param rowText - Text to identify the row
+ * @param selector - CSS selector for the action element
+ */
+ async clickRowAction(rowText: string, selector: string): Promise<void> {
+ const row = this.getRow(rowText);
+ await row.locator(selector).first().click();
+ }
}
diff --git a/superset-frontend/playwright/components/core/Toast.ts b/superset-frontend/playwright/components/core/Toast.ts
new file mode 100644
index 0000000000..a291e03c0d
--- /dev/null
+++ b/superset-frontend/playwright/components/core/Toast.ts
@@ -0,0 +1,105 @@
+/**
+ * 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';
+
+export type ToastType = 'success' | 'danger' | 'warning' | 'info';
+
+const SELECTORS = {
+ CONTAINER: '[data-test="toast-container"][role="alert"]',
+ CONTENT: '.toast__content',
+ CLOSE_BUTTON: '[data-test="close-button"]',
+} as const;
+
+/**
+ * Toast notification component
+ * Handles success, danger, warning, and info toasts
+ */
+export class Toast {
+ private page: Page;
+ private container: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.container = page.locator(SELECTORS.CONTAINER);
+ }
+
+ /**
+ * Get the toast container locator
+ */
+ get(): Locator {
+ return this.container;
+ }
+
+ /**
+ * Get the toast message text
+ */
+ getMessage(): Locator {
+ return this.container.locator(SELECTORS.CONTENT);
+ }
+
+ /**
+ * Wait for a toast to appear
+ */
+ async waitForVisible(): Promise<void> {
+ await this.container.waitFor({ state: 'visible' });
+ }
+
+ /**
+ * Wait for toast to disappear
+ */
+ async waitForHidden(): Promise<void> {
+ await this.container.waitFor({ state: 'hidden' });
+ }
+
+ /**
+ * Get a success toast
+ */
+ getSuccess(): Locator {
+ return this.page.locator(`${SELECTORS.CONTAINER}.toast--success`);
+ }
+
+ /**
+ * Get a danger/error toast
+ */
+ getDanger(): Locator {
+ return this.page.locator(`${SELECTORS.CONTAINER}.toast--danger`);
+ }
+
+ /**
+ * Get a warning toast
+ */
+ getWarning(): Locator {
+ return this.page.locator(`${SELECTORS.CONTAINER}.toast--warning`);
+ }
+
+ /**
+ * Get an info toast
+ */
+ getInfo(): Locator {
+ return this.page.locator(`${SELECTORS.CONTAINER}.toast--info`);
+ }
+
+ /**
+ * Close the toast by clicking the close button
+ */
+ async close(): Promise<void> {
+ await this.container.locator(SELECTORS.CLOSE_BUTTON).click();
+ }
+}
diff --git a/superset-frontend/playwright/helpers/api/client.ts b/superset-frontend/playwright/helpers/api/client.ts
deleted file mode 100644
index 0dcd5ee054..0000000000
--- a/superset-frontend/playwright/helpers/api/client.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-/**
- * 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 { APIRequestContext, Page } from '@playwright/test';
-
-/**
- * Gets the CSRF token from localStorage via page evaluation.
- * Required for authenticated API requests to Superset backend.
- *
- * @param page - Playwright page object (must be authenticated)
- * @returns CSRF access token string
- */
-export async function getCSRFToken(page: Page): Promise<string> {
- const token = await page.evaluate(() =>
- window.localStorage.getItem('access_token'),
- );
-
- if (!token) {
- throw new Error(
- 'CSRF token not found in localStorage. Ensure the page is authenticated.',
- );
- }
-
- return token;
-}
-
-/**
- * Extracts the base URL from the current page URL.
- * Falls back to PLAYWRIGHT_BASE_URL env var, then localhost.
- *
- * @param page - Playwright page object
- * @returns Base URL (e.g., 'http://localhost:8088' or 'http://superset:8088' in CI)
- */
-function getBaseURL(page: Page): string {
- const currentUrl = page.url();
-
- // If page has navigated, extract base URL from current URL
- if (currentUrl && currentUrl !== 'about:blank') {
- const url = new URL(currentUrl);
- return `${url.protocol}//${url.host}`;
- }
-
- // Fallback to environment variable (used in CI) or localhost (local dev)
- return process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
-}
-
-/**
- * Makes an authenticated API request to Superset with CSRF token handling.
- * Follows the same pattern as Cypress API requests.
- *
- * @param context - Playwright API request context
- * @param page - Playwright page object (for CSRF token extraction and base URL)
- * @param method - HTTP method (GET, POST, PUT, DELETE, etc.)
- * @param endpoint - API endpoint path (e.g., 'api/v1/dataset/')
- * @param data - Optional request body data
- * @param options - Additional fetch options
- * @returns Response object
- */
-export async function apiRequest(
- context: APIRequestContext,
- page: Page,
- method: string,
- endpoint: string,
- data?: Record<string, unknown>,
- options?: {
- headers?: Record<string, string>;
- params?: Record<string, string>;
- },
-) {
- const csrfToken = await getCSRFToken(page);
- const baseURL = getBaseURL(page);
-
- // Construct full URL
- const url = endpoint.startsWith('http') ? endpoint : `${baseURL}/${endpoint}`;
-
- // Prepare headers with CSRF token
- const headers = {
- 'Content-Type': 'application/json',
- 'X-CSRFToken': csrfToken,
- Cookie: `csrf_access_token=${csrfToken}`,
- Referer: `${baseURL}/`,
- ...options?.headers,
- };
-
- return context.fetch(url, {
- method,
- headers,
- data: data ? JSON.stringify(data) : undefined,
- params: options?.params,
- });
-}
-
-/**
- * Helper for GET requests
- */
-export async function apiGet(
- context: APIRequestContext,
- page: Page,
- endpoint: string,
- params?: Record<string, string>,
-) {
- return apiRequest(context, page, 'GET', endpoint, undefined, { params });
-}
-
-/**
- * Helper for POST requests
- */
-export async function apiPost(
- context: APIRequestContext,
- page: Page,
- endpoint: string,
- data?: Record<string, unknown>,
-) {
- return apiRequest(context, page, 'POST', endpoint, data);
-}
-
-/**
- * Helper for PUT requests
- */
-export async function apiPut(
- context: APIRequestContext,
- page: Page,
- endpoint: string,
- data?: Record<string, unknown>,
-) {
- return apiRequest(context, page, 'PUT', endpoint, data);
-}
-
-/**
- * Helper for DELETE requests
- */
-export async function apiDelete(
- context: APIRequestContext,
- page: Page,
- endpoint: string,
-) {
- return apiRequest(context, page, 'DELETE', endpoint);
-}
diff --git a/superset-frontend/playwright/helpers/api/database.factories.ts b/superset-frontend/playwright/helpers/api/database.factories.ts
new file mode 100644
index 0000000000..7e1ede2855
--- /dev/null
+++ b/superset-frontend/playwright/helpers/api/database.factories.ts
@@ -0,0 +1,81 @@
+/**
+ * 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 { apiPostDatabase } from './database';
+
+// Public Google Sheets URL for testing
+const NETFLIX_TITLES_SHEET =
+ 'https://docs.google.com/spreadsheets/d/19XNqckHGKGGPh83JGFdFGP4Bw9gdXeujq5EoIGwttdM/edit#gid=347941303';
+
+/**
+ * Create a Google Sheets database connection for testing
+ * Uses a public Netflix titles dataset by default
+ * @param page - Playwright page instance
+ * @param databaseName - Name for the database connection
+ * @param tableName - Name for the table/dataset
+ * @returns Database ID from the created database
+ */
+export async function createGsheetsDatabase(
+ page: Page,
+ databaseName: string,
+ tableName: string,
+): Promise<number> {
+ const requestBody = {
+ database_name: databaseName,
+ engine: 'gsheets',
+ configuration_method: 'dynamic_form',
+ engine_information: {
+ disable_ssh_tunneling: true,
+ supports_dynamic_catalog: false,
+ supports_file_upload: true,
+ supports_oauth2: true,
+ },
+ driver: 'apsw',
+ sqlalchemy_uri_placeholder: 'gsheets://',
+ extra: `{"allows_virtual_table_explore":true,"engine_params":{"catalog":{"${tableName}":"${NETFLIX_TITLES_SHEET}"}}}`,
+ expose_in_sqllab: true,
+ catalog: [
+ {
+ name: tableName,
+ value: NETFLIX_TITLES_SHEET,
+ },
+ ],
+ parameters: {
+ service_account_info: '',
+ catalog: {
+ [tableName]: NETFLIX_TITLES_SHEET,
+ },
+ },
+ masked_encrypted_extra: '{}',
+ impersonate_user: true,
+ };
+
+ const response = await apiPostDatabase(page, requestBody);
+
+ if (!response.ok()) {
+ const errorText = await response.text();
+ throw new Error(
+ `Failed to create database: ${response.status()} ${response.statusText()}\n${errorText}`,
+ );
+ }
+
+ const body = await response.json();
+ return body.id;
+}
diff --git a/superset-frontend/playwright/helpers/api/database.ts b/superset-frontend/playwright/helpers/api/database.ts
new file mode 100644
index 0000000000..5a7feb2e9b
--- /dev/null
+++ b/superset-frontend/playwright/helpers/api/database.ts
@@ -0,0 +1,52 @@
+/**
+ * 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 { apiPost, apiDelete, ApiRequestOptions } from './requests';
+
+const ENDPOINTS = {
+ DATABASE: '/api/v1/database/',
+} as const;
+
+/**
+ * POST request to create a database connection
+ * @param page - Playwright page instance (provides authentication context)
+ * @param requestBody - Database configuration object
+ * @returns API response from database creation
+ */
+export async function apiPostDatabase(
+ page: Page,
+ requestBody: object,
+): Promise<APIResponse> {
+ return apiPost(page, ENDPOINTS.DATABASE, requestBody);
+}
+
+/**
+ * DELETE request to remove a database connection
+ * @param page - Playwright page instance (provides authentication context)
+ * @param databaseId - ID of the database to delete
+ * @returns API response from database deletion
+ */
+export async function apiDeleteDatabase(
+ page: Page,
+ databaseId: number,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ return apiDelete(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
+}
diff --git a/superset-frontend/playwright/helpers/api/dataset.factories.ts b/superset-frontend/playwright/helpers/api/dataset.factories.ts
new file mode 100644
index 0000000000..6150ab2a0e
--- /dev/null
+++ b/superset-frontend/playwright/helpers/api/dataset.factories.ts
@@ -0,0 +1,64 @@
+/**
+ * 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 { createGsheetsDatabase } from './database.factories';
+import { apiPostDataset } from './dataset';
+
+/**
+ * Create a test dataset with Google Sheets database
+ * Creates both the database connection and dataset in one call
+ * @param page - Playwright page instance
+ * @param datasetName - Name for the dataset/table
+ * @returns Object containing database ID and dataset ID
+ */
+export async function createTestDataset(
+ page: Page,
+ datasetName: string,
+): Promise<{ dbId: number; datasetId: number }> {
+ // Step 1: Create Google Sheets database with catalog entry
+ // The tableName in the catalog must match the table_name used when creating the dataset
+ const dbName = `test_db_${Date.now()}`;
+ const tableName = datasetName; // Use same name for catalog entry and dataset table_name
+ const dbId = await createGsheetsDatabase(page, dbName, tableName);
+
+ // Step 2: Create dataset using the database
+ // For Google Sheets, table_name must reference the catalog entry name
+ // catalog: null is required to avoid OAuth validation issues
+ const datasetRequestBody = {
+ database: dbId,
+ catalog: null,
+ schema: 'main',
+ table_name: tableName, // Must match the catalog entry name
+ };
+
+ const response = await apiPostDataset(page, datasetRequestBody);
+
+ if (!response.ok()) {
+ const errorText = await response.text();
+ throw new Error(
+ `Failed to create dataset: ${response.status()} ${response.statusText()}\n${errorText}`,
+ );
+ }
+
+ const body = await response.json();
+ const datasetId = body.id;
+
+ return { dbId, datasetId };
+}
diff --git a/superset-frontend/playwright/helpers/api/dataset.ts b/superset-frontend/playwright/helpers/api/dataset.ts
new file mode 100644
index 0000000000..fcf8bee8a8
--- /dev/null
+++ b/superset-frontend/playwright/helpers/api/dataset.ts
@@ -0,0 +1,52 @@
+/**
+ * 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 { apiPost, apiDelete, ApiRequestOptions } from './requests';
+
+const ENDPOINTS = {
+ DATASET: '/api/v1/dataset/',
+} as const;
+
+/**
+ * POST request to create a dataset
+ * @param page - Playwright page instance (provides authentication context)
+ * @param requestBody - Dataset configuration object (database, schema, table_name)
+ * @returns API response from dataset creation
+ */
+export async function apiPostDataset(
+ page: Page,
+ requestBody: object,
+): Promise<APIResponse> {
+ return apiPost(page, ENDPOINTS.DATASET, requestBody);
+}
+
+/**
+ * DELETE request to remove a dataset
+ * @param page - Playwright page instance (provides authentication context)
+ * @param datasetId - ID of the dataset to delete
+ * @returns API response from dataset deletion
+ */
+export async function apiDeleteDataset(
+ page: Page,
+ datasetId: number,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ return apiDelete(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
+}
diff --git a/superset-frontend/playwright/helpers/api/datasets.ts b/superset-frontend/playwright/helpers/api/datasets.ts
deleted file mode 100644
index c663400fc6..0000000000
--- a/superset-frontend/playwright/helpers/api/datasets.ts
+++ /dev/null
@@ -1,272 +0,0 @@
-/**
- * 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 { APIRequestContext, Page } from '@playwright/test';
-import { apiGet, apiDelete, apiPost } from './client';
-import { TIMEOUT } from '../../utils/constants';
-
-/**
- * Filter operators for dataset queries
- */
-export interface DatasetFilter {
- eq?: string;
- sw?: string;
- // Add other operators as needed
-}
-
-/**
- * Filters for dataset list queries
- */
-export interface DatasetListFilters {
- table_name?: DatasetFilter;
- database?: DatasetFilter;
- schema?: DatasetFilter;
- // Add other filterable fields as needed
-}
-
-/**
- * Dataset configuration for creation
- */
-export interface DatasetConfig {
- databaseId: number; // Numeric database ID (required by API)
- schema?: string;
- table_name: string;
- dataset_name?: string;
-}
-
-/**
- * Dataset response from API
- */
-export interface Dataset {
- id: number;
- table_name: string;
- database: {
- id: number;
- database_name: string;
- };
- schema?: string;
- sql?: string;
- owners?: Array<{ id: number; username: string }>;
- changed_on_delta_humanized?: string;
- explore_url?: string;
-}
-
-/**
- * Lists datasets from the Superset API.
- *
- * @param context - API request context
- * @param page - Playwright page object
- * @param filters - Optional filters (e.g., { table_name: { sw: 'test_' } })
- * @returns Array of datasets
- */
-export async function listDatasets(
- context: APIRequestContext,
- page: Page,
- filters?: DatasetListFilters,
-): Promise<Dataset[]> {
- const params: Record<string, string> = {};
-
- if (filters) {
- params.q = JSON.stringify({ filters });
- }
-
- const response = await apiGet(context, page, 'api/v1/dataset/', params);
-
- if (!response.ok()) {
- throw new Error(
- `Failed to list datasets: ${response.status()} ${await response.text()}`,
- );
- }
-
- const data = await response.json();
- return data.result || [];
-}
-
-/**
- * Deletes a dataset by ID.
- *
- * @param context - API request context
- * @param page - Playwright page object
- * @param datasetId - Dataset ID to delete
- * @returns true if deletion was successful
- */
-export async function deleteDataset(
- context: APIRequestContext,
- page: Page,
- datasetId: number,
-): Promise<boolean> {
- const response = await apiDelete(
- context,
- page,
- `api/v1/dataset/${datasetId}`,
- );
-
- if (!response.ok()) {
- const text = await response.text();
- console.warn(
- `Failed to delete dataset ${datasetId}: ${response.status()} ${text}`,
- );
- return false;
- }
-
- return true;
-}
-
-/**
- * Deletes a dataset by name (table_name).
- * Finds the dataset by name first, then deletes it.
- *
- * @param context - API request context
- * @param page - Playwright page object
- * @param datasetName - Dataset name to delete
- * @returns true if deletion was successful
- */
-export async function deleteDatasetByName(
- context: APIRequestContext,
- page: Page,
- datasetName: string,
-): Promise<boolean> {
- const datasets = await listDatasets(context, page, {
- table_name: { eq: datasetName },
- });
-
- if (datasets.length === 0) {
- console.log(`Dataset '${datasetName}' not found, skipping deletion`);
- return true; // Not found is considered successful cleanup
- }
-
- // Delete all matching datasets (in case of duplicates)
- const deletePromises = datasets.map(dataset =>
- deleteDataset(context, page, dataset.id),
- );
-
- const results = await Promise.all(deletePromises);
- return results.every(result => result);
-}
-
-/**
- * Cleans up test datasets matching a pattern.
- * Useful for beforeEach hooks to ensure clean state.
- *
- * @param context - API request context
- * @param page - Playwright page object
- * @param pattern - SQL LIKE pattern (e.g., 'test_dataset_%')
- * @returns Number of datasets deleted
- */
-export async function cleanTestDatasets(
- context: APIRequestContext,
- page: Page,
- pattern: string,
-): Promise<number> {
- // Convert SQL LIKE pattern to filter
- // Pattern 'test_dataset_%' becomes starts with 'test_dataset_'
- const prefix = pattern.replace(/%$/, '');
-
- const datasets = await listDatasets(context, page, {
- table_name: { sw: prefix }, // 'sw' = starts with
- });
-
- if (datasets.length === 0) {
- return 0;
- }
-
- console.log(
- `Cleaning ${datasets.length} test dataset(s) matching '${pattern}'`,
- );
-
- const deletePromises = datasets.map(dataset =>
- deleteDataset(context, page, dataset.id),
- );
-
- const results = await Promise.all(deletePromises);
- const successCount = results.filter(result => result).length;
-
- console.log(
- `Successfully cleaned ${successCount}/${datasets.length} test datasets`,
- );
-
- return successCount;
-}
-
-/**
- * Creates a new dataset.
- *
- * @param context - API request context
- * @param page - Playwright page object
- * @param config - Dataset configuration
- * @returns Created dataset ID
- */
-export async function createDataset(
- context: APIRequestContext,
- page: Page,
- config: DatasetConfig,
-): Promise<number> {
- const payload = {
- database: config.databaseId, // Numeric ID (required by API)
- schema: config.schema,
- table_name: config.table_name,
- dataset_name: config.dataset_name || config.table_name,
- };
-
- const response = await apiPost(context, page, 'api/v1/dataset/', payload);
-
- if (!response.ok()) {
- throw new Error(
- `Failed to create dataset: ${response.status()} ${await response.text()}`,
- );
- }
-
- const data = await response.json();
- return data.id;
-}
-
-/**
- * Waits for a dataset to appear in the list with the given name.
- * Useful after creation operations to ensure backend processing is complete.
- *
- * @param context - API request context
- * @param page - Playwright page object
- * @param datasetName - Dataset name to wait for
- * @param options - Wait options
- * @returns true if dataset found
- */
-export async function waitForDataset(
- context: APIRequestContext,
- page: Page,
- datasetName: string,
- options?: { timeout?: number; interval?: number },
-): Promise<boolean> {
- const timeout = options?.timeout || TIMEOUT.API_POLL_TIMEOUT;
- const interval = options?.interval || TIMEOUT.API_POLL_INTERVAL;
- const startTime = Date.now();
-
- while (Date.now() - startTime < timeout) {
- const datasets = await listDatasets(context, page, {
- table_name: { eq: datasetName },
- });
-
- if (datasets.length > 0) {
- return true;
- }
-
- await new Promise(resolve => setTimeout(resolve, interval));
- }
-
- return false;
-}
diff --git a/superset-frontend/playwright/helpers/api/requests.ts b/superset-frontend/playwright/helpers/api/requests.ts
new file mode 100644
index 0000000000..66e91e39c6
--- /dev/null
+++ b/superset-frontend/playwright/helpers/api/requests.ts
@@ -0,0 +1,178 @@
+/**
+ * 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';
+
+export interface ApiRequestOptions {
+ headers?: Record<string, string>;
+ params?: Record<string, string>;
+ failOnStatusCode?: boolean;
+}
+
+/**
+ * Get base URL for Referer header
+ * Reads from environment variable configured in playwright.config.ts
+ * Preserves full base URL including path prefix (e.g., /app/prefix)
+ */
+function getBaseUrl(_page: Page): string {
+ // Use environment variable which includes path prefix if configured
+ // This matches playwright.config.ts baseURL setting exactly
+ return process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
+}
+
+/**
+ * Get CSRF token from the API endpoint
+ * Superset provides a CSRF token via /api/v1/security/csrf_token/
+ * The session cookie is automatically included by page.request
+ */
+async function getCsrfToken(page: Page): Promise<string> {
+ try {
+ const response = await page.request.get('/api/v1/security/csrf_token/', {
+ failOnStatusCode: false,
+ });
+
+ if (!response.ok()) {
+ console.warn('[CSRF] Failed to fetch CSRF token:', response.status());
+ return '';
+ }
+
+ const json = await response.json();
+ return json.result || '';
+ } catch (error) {
+ console.warn('[CSRF] Error fetching CSRF token:', error);
+ return '';
+ }
+}
+
+/**
+ * Build headers for mutation requests (POST, PUT, PATCH, DELETE)
+ * Includes CSRF token and Referer for Flask-WTF CSRFProtect
+ */
+async function buildHeaders(
+ page: Page,
+ options?: ApiRequestOptions,
+): Promise<Record<string, string>> {
+ const csrfToken = await getCsrfToken(page);
+ const headers: Record<string, string> = {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ };
+
+ // Include CSRF token and Referer for Flask-WTF CSRFProtect
+ if (csrfToken) {
+ headers['X-CSRFToken'] = csrfToken;
+ headers['Referer'] = getBaseUrl(page);
+ }
+
+ return headers;
+}
+
+/**
+ * Send a GET request
+ * Uses page.request to automatically include browser authentication
+ */
+export async function apiGet(
+ page: Page,
+ url: string,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ return page.request.get(url, {
+ headers: options?.headers,
+ params: options?.params,
+ failOnStatusCode: options?.failOnStatusCode ?? true,
+ });
+}
+
+/**
+ * Send a POST request
+ * Uses page.request to automatically include browser authentication
+ */
+export async function apiPost(
+ page: Page,
+ url: string,
+ data?: unknown,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ const headers = await buildHeaders(page, options);
+
+ return page.request.post(url, {
+ data,
+ headers,
+ params: options?.params,
+ failOnStatusCode: options?.failOnStatusCode ?? true,
+ });
+}
+
+/**
+ * Send a PUT request
+ * Uses page.request to automatically include browser authentication
+ */
+export async function apiPut(
+ page: Page,
+ url: string,
+ data?: unknown,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ const headers = await buildHeaders(page, options);
+
+ return page.request.put(url, {
+ data,
+ headers,
+ params: options?.params,
+ failOnStatusCode: options?.failOnStatusCode ?? true,
+ });
+}
+
+/**
+ * Send a PATCH request
+ * Uses page.request to automatically include browser authentication
+ */
+export async function apiPatch(
+ page: Page,
+ url: string,
+ data?: unknown,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ const headers = await buildHeaders(page, options);
+
+ return page.request.patch(url, {
+ data,
+ headers,
+ params: options?.params,
+ failOnStatusCode: options?.failOnStatusCode ?? true,
+ });
+}
+
+/**
+ * Send a DELETE request
+ * Uses page.request to automatically include browser authentication
+ */
+export async function apiDelete(
+ page: Page,
+ url: string,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ const headers = await buildHeaders(page, options);
+
+ return page.request.delete(url, {
+ headers,
+ params: options?.params,
+ failOnStatusCode: options?.failOnStatusCode ?? true,
+ });
+}
diff --git a/superset-frontend/playwright/pages/DatasetListPage.ts b/superset-frontend/playwright/pages/DatasetListPage.ts
index 3f2a516a22..a7b6af75a1 100644
--- a/superset-frontend/playwright/pages/DatasetListPage.ts
+++ b/superset-frontend/playwright/pages/DatasetListPage.ts
@@ -30,6 +30,9 @@ export class DatasetListPage {
private static readonly SELECTORS = {
DATASET_LINK: '[data-test="internal-link"]',
+ DELETE_ACTION: '.action-button svg[data-icon="delete"]',
+ EXPORT_ACTION: '.action-button svg[data-icon="upload"]',
+ DUPLICATE_ACTION: '.action-button svg[data-icon="copy"]',
} as const;
constructor(page: Page) {
@@ -76,4 +79,37 @@ export class DatasetListPage {
DatasetListPage.SELECTORS.DATASET_LINK,
);
}
+
+ /**
+ * Clicks the delete action button for a dataset
+ * @param datasetName - The name of the dataset to delete
+ */
+ async clickDeleteAction(datasetName: string): Promise<void> {
+ await this.table.clickRowAction(
+ datasetName,
+ DatasetListPage.SELECTORS.DELETE_ACTION,
+ );
+ }
+
+ /**
+ * Clicks the export action button for a dataset
+ * @param datasetName - The name of the dataset to export
+ */
+ async clickExportAction(datasetName: string): Promise<void> {
+ await this.table.clickRowAction(
+ datasetName,
+ DatasetListPage.SELECTORS.EXPORT_ACTION,
+ );
+ }
+
+ /**
+ * Clicks the duplicate action button for a dataset (virtual datasets only)
+ * @param datasetName - The name of the dataset to duplicate
+ */
+ async clickDuplicateAction(datasetName: string): Promise<void> {
+ await this.table.clickRowAction(
+ datasetName,
+ DatasetListPage.SELECTORS.DUPLICATE_ACTION,
+ );
+ }
}
diff --git a/superset-frontend/playwright/tests/dataset/dataset-list.spec.ts b/superset-frontend/playwright/tests/dataset/dataset-list.spec.ts
index e1f7a17e29..f2a1a8dd02 100644
--- a/superset-frontend/playwright/tests/dataset/dataset-list.spec.ts
+++ b/superset-frontend/playwright/tests/dataset/dataset-list.spec.ts
@@ -17,23 +17,60 @@
* under the License.
*/
-import { test, expect } from '@playwright/test';
+import { test, expect, Page } from '@playwright/test';
import { DatasetListPage } from '../../pages/DatasetListPage';
import { ExplorePage } from '../../pages/ExplorePage';
+import { DeleteConfirmationModal } from '../../components/modals/DeleteConfirmationModal';
+import { Toast } from '../../components/core/Toast';
+import { createTestDataset } from '../../helpers/api/dataset.factories';
+import { apiDeleteDataset } from '../../helpers/api/dataset';
+import { apiDeleteDatabase } from '../../helpers/api/database';
test.describe('Dataset List', () => {
let datasetListPage: DatasetListPage;
let explorePage: ExplorePage;
+ let testResources: { datasetId?: number; dbId?: number } = {};
test.beforeEach(async ({ page }) => {
datasetListPage = new DatasetListPage(page);
explorePage = new ExplorePage(page);
+ testResources = {}; // Reset for each test
// Navigate to dataset list page
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
});
+ test.afterEach(async ({ page }) => {
+ // Cleanup any resources created during the test
+ await cleanupTestAssets(page, testResources);
+ });
+
+ function cleanupTestAssets(
+ page: Page,
+ resources: { datasetId?: number; dbId?: number },
+ ) {
+ const promises = [];
+
+ if (resources.datasetId) {
+ promises.push(
+ apiDeleteDataset(page, resources.datasetId, {
+ failOnStatusCode: false,
+ }).catch(() => {}),
+ );
+ }
+
+ if (resources.dbId) {
+ promises.push(
+ apiDeleteDatabase(page, resources.dbId, {
+ failOnStatusCode: false,
+ }).catch(() => {}),
+ );
+ }
+
+ return Promise.all(promises);
+ }
+
test('should navigate to Explore when dataset name is clicked', async () => {
// Test uses existing fixture dataset 'birth_names'
// (available in Superset examples database)
@@ -55,4 +92,42 @@ test.describe('Dataset List', () => {
await expect(explorePage.getVizSwitcher()).toBeVisible();
await expect(explorePage.getVizSwitcher()).toContainText('Table');
});
+
+ test('should delete a dataset with confirmation', async ({ page }) => {
+ // Create test dataset
+ const datasetName = `test_delete_${Date.now()}`;
+ testResources = await createTestDataset(page, datasetName);
+
+ // Refresh page to see new dataset
+ await datasetListPage.goto();
+ await datasetListPage.waitForTableLoad();
+
+ // Verify dataset is visible in list
+ await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
+
+ // Click delete action button
+ await datasetListPage.clickDeleteAction(datasetName);
+
+ // 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 with correct message
+ const toast = new Toast(page);
+ const successToast = toast.getSuccess();
+ await expect(successToast).toBeVisible();
+ await expect(toast.getMessage()).toContainText('Deleted');
+
+ // Verify dataset is removed from list
+ await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
+ });
});