sadpandajoe commented on code in PR #36196:
URL: https://github.com/apache/superset/pull/36196#discussion_r2583888807


##########
superset-frontend/playwright/helpers/api/requests.ts:
##########
@@ -0,0 +1,191 @@
+/**
+ * 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;
+  allowMissingCsrf?: 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(): 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';
+}
+
+interface CsrfResult {
+  token: string;
+  error?: string;
+}
+
+/**
+ * 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<CsrfResult> {
+  try {
+    const response = await page.request.get('api/v1/security/csrf_token/', {
+      failOnStatusCode: false,
+    });
+
+    if (!response.ok()) {
+      return {
+        token: '',
+        error: `HTTP ${response.status()} ${response.statusText()}`,
+      };
+    }
+
+    const json = await response.json();
+    return { token: json.result || '' };
+  } catch (error) {
+    return { token: '', error: String(error) };
+  }
+}
+
+/**
+ * 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 { token: csrfToken, error: csrfError } = 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();
+  } else if (!options?.allowMissingCsrf) {
+    const errorDetail = csrfError ? ` (${csrfError})` : '';
+    throw new Error(
+      `Missing CSRF token${errorDetail} - mutation requests require 
authentication. ` +
+        'Ensure global authentication completed or test has valid session.',

Review Comment:
   Good point. The option is only used in test cleanup (`afterEach`) where CSRF 
may be unavailable. Added context in the interface docstring.



##########
superset-frontend/playwright/tests/experimental/dataset/dataset-list.spec.ts:
##########
@@ -0,0 +1,243 @@
+/**
+ * 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, expect } from '@playwright/test';
+import { DatasetListPage } from '../../../pages/DatasetListPage';
+import { ExplorePage } from '../../../pages/ExplorePage';
+import { DeleteConfirmationModal } from 
'../../../components/modals/DeleteConfirmationModal';
+import { DuplicateDatasetModal } from 
'../../../components/modals/DuplicateDatasetModal';
+import { Toast } from '../../../components/core/Toast';
+import {
+  apiDeleteDataset,
+  apiGetDataset,
+  getDatasetByName,
+  ENDPOINTS,
+} from '../../../helpers/api/dataset';
+
+/**
+ * Test data constants
+ * These reference example datasets loaded via --load-examples in CI
+ */
+const TEST_DATASETS = {
+  EXAMPLE_DATASET: 'members_channels_2',
+} as const;
+
+test.describe('Dataset List', () => {
+  let datasetListPage: DatasetListPage;
+  let explorePage: ExplorePage;
+  let testResources: { datasetIds: number[] } = { datasetIds: [] };
+
+  test.beforeEach(async ({ page }) => {
+    datasetListPage = new DatasetListPage(page);
+    explorePage = new ExplorePage(page);
+    testResources = { datasetIds: [] }; // 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
+    const promises = [];
+    for (const datasetId of testResources.datasetIds) {
+      promises.push(
+        apiDeleteDataset(page, datasetId, {
+          failOnStatusCode: false,
+        }).catch(error => {
+          // Log cleanup failures to avoid silent resource leaks
+          console.warn(
+            `[Cleanup] Failed to delete dataset ${datasetId}:`,
+            String(error),
+          );
+        }),
+      );
+    }
+    await Promise.all(promises);
+  });
+
+  test('should navigate to Explore when dataset name is clicked', async ({
+    page,
+  }) => {
+    // Use existing example dataset (hermetic - loaded in CI via 
--load-examples)
+    const datasetName = TEST_DATASETS.EXAMPLE_DATASET;
+    const dataset = await getDatasetByName(page, datasetName);
+    expect(dataset).not.toBeNull();
+
+    // Verify dataset is visible in list (uses page object + Playwright 
auto-wait)
+    await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
+
+    // Click on dataset name to navigate to Explore
+    await datasetListPage.clickDatasetName(datasetName);
+
+    // Wait for Explore page to load (validates URL + datasource control)
+    await explorePage.waitForPageLoad();
+
+    // Verify correct dataset is loaded in datasource control
+    const loadedDatasetName = await explorePage.getDatasetName();
+    expect(loadedDatasetName).toContain(datasetName);
+
+    // Verify visualization switcher shows default viz type (indicates full 
page load)
+    await expect(explorePage.getVizSwitcher()).toBeVisible();
+    await expect(explorePage.getVizSwitcher()).toContainText('Table');
+  });
+
+  test('should delete a dataset with confirmation', async ({ page }) => {
+    // Get example dataset to duplicate
+    const originalName = TEST_DATASETS.EXAMPLE_DATASET;
+    const originalDataset = await getDatasetByName(page, originalName);
+    expect(originalDataset).not.toBeNull();
+
+    // Create throwaway copy for deletion (hermetic - uses UI duplication)
+    const datasetName = `test_delete_${Date.now()}`;
+
+    // Verify original dataset is visible in list
+    await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
+
+    // Set up response intercept to capture duplicate dataset ID
+    const duplicateResponsePromise = page.waitForResponse(
+      response =>
+        response.url().includes(`${ENDPOINTS.DATASET}duplicate`) &&
+        response.status() === 201,
+    );
+
+    // Click duplicate action button
+    await datasetListPage.clickDuplicateAction(originalName);
+
+    // Duplicate modal should appear and be ready for interaction
+    const duplicateModal = new DuplicateDatasetModal(page);
+    await duplicateModal.waitForReady();
+
+    // Fill in new dataset name
+    await duplicateModal.fillDatasetName(datasetName);
+
+    // Click the Duplicate button
+    await duplicateModal.clickDuplicate();
+
+    // Get the duplicate dataset ID from response and track immediately
+    const duplicateResponse = await duplicateResponsePromise;
+    const duplicateData = await duplicateResponse.json();
+    const duplicateId = duplicateData.id;
+
+    // Track duplicate for cleanup immediately (before any operations that 
could fail)
+    testResources = { datasetIds: [duplicateId] };
+
+    // Modal should close
+    await duplicateModal.waitForHidden();
+
+    // 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');

Review Comment:
   The 'Deleted' text is the exact toast message Superset shows. Partial 
matching is intentional to be resilient to minor wording changes.



##########
superset-frontend/playwright/global-setup.ts:
##########
@@ -0,0 +1,91 @@
+/**
+ * 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 {
+  chromium,
+  FullConfig,
+  Browser,
+  BrowserContext,
+} from '@playwright/test';
+import { mkdir } from 'fs/promises';
+import { dirname } from 'path';
+import { AuthPage } from './pages/AuthPage';
+
+/**
+ * Global setup function that runs once before all tests.
+ * Authenticates as admin user and saves the authentication state
+ * to be reused by tests in the 'chromium' project (E2E tests).
+ *
+ * Auth tests (chromium-unauth project) don't use this - they login
+ * per-test via beforeEach for isolation and simplicity.
+ */
+async function globalSetup(config: FullConfig) {
+  // Get baseURL with fallback to default
+  // FullConfig.use doesn't exist in the type - baseURL is only in 
projects[0].use
+  const baseURL = config.projects[0]?.use?.baseURL || 'http://localhost:8088';

Review Comment:
   Correct - this is a workaround for Playwright's FullConfig type. An empty 
projects array would indicate a broken config, which would fail earlier.



##########
superset-frontend/playwright/helpers/api/dataset.ts:
##########
@@ -0,0 +1,165 @@
+/**
+ * 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 { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
+
+export const ENDPOINTS = {
+  DATASET: 'api/v1/dataset/',
+} as const;
+
+/**
+ * TypeScript interface for dataset creation API payload
+ * Provides compile-time safety for required fields
+ */
+export interface DatasetCreatePayload {
+  database: number;
+  catalog: string | null;
+  schema: string;
+  table_name: string;
+}
+
+/**
+ * TypeScript interface for dataset API response
+ * Represents the shape of dataset data returned from the API
+ */
+export interface DatasetResult {
+  id: number;
+  table_name: string;
+  sql?: string;
+  schema?: string;
+  database: {
+    id: number;
+    database_name: string;
+  };
+  owners?: Array<{ id: number }>;
+  dataset_type?: 'physical' | 'virtual';
+}
+
+/**
+ * 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: DatasetCreatePayload,
+): Promise<APIResponse> {
+  return apiPost(page, ENDPOINTS.DATASET, requestBody);
+}
+
+/**
+ * Get a dataset by its table name
+ * @param page - Playwright page instance (provides authentication context)
+ * @param tableName - The table_name to search for
+ * @returns Dataset object if found, null if not found
+ */
+export async function getDatasetByName(
+  page: Page,
+  tableName: string,
+): Promise<DatasetResult | null> {
+  // Use Superset's filter API to search by table_name
+  const filter = {
+    filters: [
+      {
+        col: 'table_name',
+        opr: 'eq',
+        value: tableName,
+      },
+    ],
+  };
+  const queryParam = encodeURIComponent(JSON.stringify(filter));
+  const response = await apiGet(page, `${ENDPOINTS.DATASET}?q=${queryParam}`);
+
+  if (!response.ok()) {
+    return null;
+  }
+
+  const body = await response.json();
+  if (body.result && body.result.length > 0) {
+    return body.result[0] as DatasetResult;
+  }
+
+  return null;
+}
+
+/**
+ * Duplicates a dataset via API and returns the new dataset info
+ * @param page - Playwright page instance (provides authentication context)
+ * @param datasetId - ID of dataset to duplicate
+ * @param newName - Name for the duplicated dataset
+ * @returns Promise resolving to new dataset info
+ */
+export async function duplicateDataset(
+  page: Page,
+  datasetId: number,
+  newName: string,
+): Promise<DatasetResult> {
+  const response = await apiPost(page, `${ENDPOINTS.DATASET}duplicate`, {
+    base_model_id: datasetId,
+    table_name: newName,
+  });
+
+  if (response.status() !== 201) {
+    throw new Error(
+      `Failed to duplicate dataset ${datasetId}: expected 201, got 
${response.status()}`,

Review Comment:
   Response body is included - error message shows HTTP status + statusText, 
and the thrown Error includes the full context. Server errors surface clearly 
in test output.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to