test(playwright): convert and create new dataset list playwright tests (#36196)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Joe Li
2025-12-16 11:07:11 -08:00
committed by GitHub
parent 821b259805
commit d0361cb881
24 changed files with 1778 additions and 111 deletions

View File

@@ -117,6 +117,19 @@ testdata() {
say "::endgroup::"
}
playwright_testdata() {
cd "$GITHUB_WORKSPACE"
say "::group::Load all examples for Playwright tests"
# must specify PYTHONPATH to make `tests.superset_test_config` importable
export PYTHONPATH="$GITHUB_WORKSPACE"
pip install -e .
superset db upgrade
superset load_test_users
superset load_examples
superset init
say "::endgroup::"
}
celery-worker() {
cd "$GITHUB_WORKSPACE"
say "::group::Start Celery worker"

View File

@@ -223,7 +223,7 @@ jobs:
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: testdata
run: playwright_testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v6

View File

@@ -97,7 +97,7 @@ jobs:
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: testdata
run: playwright_testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v6

View File

@@ -1,6 +1,9 @@
coverage/*
cypress/screenshots
cypress/videos
playwright/.auth
playwright-report/
test-results/
src/temp
.temp_cache/
.tsbuildinfo

View File

@@ -33,6 +33,9 @@ export default defineConfig({
? undefined
: '**/experimental/**',
// Global setup - authenticate once before all tests
globalSetup: './playwright/global-setup.ts',
// Timeout settings
timeout: 30000,
expect: { timeout: 8000 },
@@ -60,7 +63,11 @@ export default defineConfig({
// Global test setup
use: {
// Use environment variable for base URL in CI, default to localhost:8088 for local
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088',
// Normalize to always end with '/' to prevent URL resolution issues with APP_PREFIX
baseURL: (() => {
const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
return url.endsWith('/') ? url : `${url}/`;
})(),
// Browser settings
headless: !!process.env.CI,
@@ -77,10 +84,32 @@ export default defineConfig({
projects: [
{
// Default project - uses global authentication for speed
// E2E tests login once via global-setup.ts and reuse auth state
// Explicitly ignore auth tests (they run in chromium-unauth project)
// Also respect the global experimental testIgnore setting
name: 'chromium',
testIgnore: [
'**/tests/auth/**/*.spec.ts',
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
],
use: {
browserName: 'chromium',
testIdAttribute: 'data-test',
// Reuse authentication state from global setup (fast E2E tests)
storageState: 'playwright/.auth/user.json',
},
},
{
// Separate project for unauthenticated tests (login, signup, etc.)
// These tests use beforeEach for per-test navigation - no global auth
// This hybrid approach: simple auth tests, fast E2E tests
name: 'chromium-unauth',
testMatch: '**/tests/auth/**/*.spec.ts',
use: {
browserName: 'chromium',
testIdAttribute: 'data-test',
// No storageState = clean browser with no cached cookies
},
},
],

View File

@@ -0,0 +1,118 @@
/**
* 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 { Locator, Page } from '@playwright/test';
/**
* Base Modal component for Ant Design modals.
* Provides minimal primitives - extend this for specific modal types.
* Add methods to this class only when multiple modal types need them (YAGNI).
*
* @example
* class DeleteConfirmationModal extends Modal {
* async clickDelete(): Promise<void> {
* await this.footer.locator('button', { hasText: 'Delete' }).click();
* }
* }
*/
export class Modal {
protected readonly page: Page;
protected readonly modalSelector: string;
// Ant Design modal structure selectors (shared by all modal types)
protected static readonly BASE_SELECTORS = {
FOOTER: '.ant-modal-footer',
BODY: '.ant-modal-body',
};
constructor(page: Page, modalSelector = '[role="dialog"]') {
this.page = page;
this.modalSelector = modalSelector;
}
/**
* Gets the modal element locator
*/
get element(): Locator {
return this.page.locator(this.modalSelector);
}
/**
* Gets the modal footer locator (contains action buttons)
*/
get footer(): Locator {
return this.element.locator(Modal.BASE_SELECTORS.FOOTER);
}
/**
* Gets the modal body locator (contains content)
*/
get body(): Locator {
return this.element.locator(Modal.BASE_SELECTORS.BODY);
}
/**
* Gets a footer button by text content (private helper)
* @param buttonText - The text content of the button
*/
private getFooterButton(buttonText: string): Locator {
return this.footer.getByRole('button', { name: buttonText, exact: true });
}
/**
* Clicks a footer button by text content
* @param buttonText - The text content of the button to click
* @param options - Optional click options
*/
protected async clickFooterButton(
buttonText: string,
options?: { timeout?: number; force?: boolean; delay?: number },
): Promise<void> {
await this.getFooterButton(buttonText).click(options);
}
/**
* Waits for the modal to become visible
* @param options - Optional wait options
*/
async waitForVisible(options?: { timeout?: number }): Promise<void> {
await this.element.waitFor({ state: 'visible', ...options });
}
/**
* Waits for the modal to be fully ready for interaction.
* This includes waiting for the modal dialog to be visible AND for React to finish
* rendering the modal content. Use this before interacting with modal elements
* to avoid race conditions with React state updates.
*
* @param options - Optional wait options
*/
async waitForReady(options?: { timeout?: number }): Promise<void> {
await this.waitForVisible(options);
await this.body.waitFor({ state: 'visible', ...options });
}
/**
* Waits for the modal to be hidden
* @param options - Optional wait options
*/
async waitForHidden(options?: { timeout?: number }): Promise<void> {
await this.element.waitFor({ state: 'hidden', ...options });
}
}

View File

@@ -0,0 +1,102 @@
/**
* 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 { Locator, Page } from '@playwright/test';
/**
* Table component for Superset ListView tables.
*/
export class Table {
private readonly page: Page;
private readonly tableSelector: string;
private static readonly SELECTORS = {
TABLE_ROW: '[data-test="table-row"]',
};
constructor(page: Page, tableSelector = '[data-test="listview-table"]') {
this.page = page;
this.tableSelector = tableSelector;
}
/**
* Gets the table element locator
*/
get element(): Locator {
return this.page.locator(this.tableSelector);
}
/**
* Gets a table row by exact text match in the first cell (dataset name column).
* Uses exact match to avoid substring collisions (e.g., 'members_channels_2' vs 'duplicate_members_channels_2_123').
*
* Note: Returns a Locator that will auto-wait when used in assertions or actions.
* If row doesn't exist, operations on the locator will timeout with clear error.
*
* @param rowText - Exact text to find in the row's first cell
* @returns Locator for the matching row
*/
getRow(rowText: string): Locator {
return this.element.locator(Table.SELECTORS.TABLE_ROW).filter({
has: this.page.getByRole('cell', { name: rowText, exact: true }),
});
}
/**
* Clicks a link within a specific row
* @param rowText - Text to identify the row
* @param linkSelector - Selector for the link within the row
*/
async clickRowLink(rowText: string, linkSelector: string): Promise<void> {
const row = this.getRow(rowText);
await row.locator(linkSelector).click();
}
/**
* Waits for the table to be visible
* @param options - Optional wait options
*/
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);
const actionButton = row.locator(selector);
const count = await actionButton.count();
if (count === 0) {
throw new Error(
`No action button found with selector "${selector}" in row "${rowText}"`,
);
}
if (count > 1) {
throw new Error(
`Multiple action buttons (${count}) found with selector "${selector}" in row "${rowText}". Use more specific selector.`,
);
}
await actionButton.click();
}
}

View File

@@ -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();
}
}

View File

@@ -21,3 +21,5 @@
export { Button } from './Button';
export { Form } from './Form';
export { Input } from './Input';
export { Modal } from './Modal';
export { Table } from './Table';

View File

@@ -0,0 +1,75 @@
/**
* 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 { Modal, Input } from '../core';
/**
* Delete confirmation modal that requires typing "DELETE" to confirm.
* Used throughout Superset for destructive delete operations.
*
* Provides primitives for tests to compose deletion flows.
*/
export class DeleteConfirmationModal extends Modal {
private static readonly SELECTORS = {
CONFIRMATION_INPUT: 'input[type="text"]',
};
/**
* Gets the confirmation input component
*/
private get confirmationInput(): Input {
return new Input(
this.page,
this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT),
);
}
/**
* Fills the confirmation input with the specified text.
*
* @param confirmationText - The text to type
* @param options - Optional fill options (timeout, force)
*
* @example
* const deleteModal = new DeleteConfirmationModal(page);
* await deleteModal.waitForVisible();
* await deleteModal.fillConfirmationInput('DELETE');
* await deleteModal.clickDelete();
* await deleteModal.waitForHidden();
*/
async fillConfirmationInput(
confirmationText: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.confirmationInput.fill(confirmationText, options);
}
/**
* Clicks the Delete button in the footer
*
* @param options - Optional click options (timeout, force, delay)
*/
async clickDelete(options?: {
timeout?: number;
force?: boolean;
delay?: number;
}): Promise<void> {
await this.clickFooterButton('Delete', options);
}
}

View File

@@ -0,0 +1,73 @@
/**
* 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 { Modal, Input } from '../core';
/**
* Duplicate dataset modal that requires entering a new dataset name.
* Used for duplicating virtual datasets with custom SQL.
*/
export class DuplicateDatasetModal extends Modal {
private static readonly SELECTORS = {
NAME_INPUT: '[data-test="duplicate-modal-input"]',
};
/**
* Gets the new dataset name input component
*/
private get nameInput(): Input {
return new Input(
this.page,
this.body.locator(DuplicateDatasetModal.SELECTORS.NAME_INPUT),
);
}
/**
* Fills the new dataset name input
*
* @param datasetName - The new name for the duplicated dataset
* @param options - Optional fill options (timeout, force)
*
* @example
* const duplicateModal = new DuplicateDatasetModal(page);
* await duplicateModal.waitForVisible();
* await duplicateModal.fillDatasetName('my_dataset_copy');
* await duplicateModal.clickDuplicate();
* await duplicateModal.waitForHidden();
*/
async fillDatasetName(
datasetName: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.nameInput.fill(datasetName, options);
}
/**
* Clicks the Duplicate button in the footer
*
* @param options - Optional click options (timeout, force, delay)
*/
async clickDuplicate(options?: {
timeout?: number;
force?: boolean;
delay?: number;
}): Promise<void> {
await this.clickFooterButton('Duplicate', options);
}
}

View File

@@ -0,0 +1,22 @@
/**
* 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.
*/
// Specific modal implementations
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';

View File

@@ -0,0 +1,93 @@
/**
* 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';
import { TIMEOUT } from './utils/constants';
/**
* 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';
// Test credentials - can be overridden via environment variables
const adminUsername = process.env.PLAYWRIGHT_ADMIN_USERNAME || 'admin';
const adminPassword = process.env.PLAYWRIGHT_ADMIN_PASSWORD || 'general';
console.log('[Global Setup] Authenticating as admin user...');
let browser: Browser | null = null;
let context: BrowserContext | null = null;
try {
// Launch browser
browser = await chromium.launch();
} catch (error) {
console.error('[Global Setup] Failed to launch browser:', error);
throw new Error('Browser launch failed - check Playwright installation');
}
try {
context = await browser.newContext({ baseURL });
const page = await context.newPage();
// Use AuthPage to handle login logic (DRY principle)
const authPage = new AuthPage(page);
await authPage.goto();
await authPage.waitForLoginForm();
await authPage.loginWithCredentials(adminUsername, adminPassword);
// Use longer timeout for global setup (cold CI starts may exceed PAGE_LOAD timeout)
await authPage.waitForLoginSuccess({ timeout: TIMEOUT.GLOBAL_SETUP });
// Save authentication state for all tests to reuse
const authStatePath = 'playwright/.auth/user.json';
await mkdir(dirname(authStatePath), { recursive: true });
await context.storageState({
path: authStatePath,
});
console.log(
'[Global Setup] Authentication successful - state saved to playwright/.auth/user.json',
);
} catch (error) {
console.error('[Global Setup] Authentication failed:', error);
throw error;
} finally {
// Ensure cleanup even if auth fails
if (context) await context.close();
if (browser) await browser.close();
}
}
export default globalSetup;

View File

@@ -0,0 +1,79 @@
/**
* 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;
/**
* TypeScript interface for database creation API payload
* Provides compile-time safety for required fields
*/
export interface DatabaseCreatePayload {
database_name: string;
engine: string;
configuration_method?: string;
engine_information?: {
disable_ssh_tunneling?: boolean;
supports_dynamic_catalog?: boolean;
supports_file_upload?: boolean;
supports_oauth2?: boolean;
};
driver?: string;
sqlalchemy_uri_placeholder?: string;
extra?: string;
expose_in_sqllab?: boolean;
catalog?: Array<{ name: string; value: string }>;
parameters?: {
service_account_info?: string;
catalog?: Record<string, string>;
};
masked_encrypted_extra?: string;
impersonate_user?: boolean;
}
/**
* POST request to create a database connection
* @param page - Playwright page instance (provides authentication context)
* @param requestBody - Database configuration object with type safety
* @returns API response from database creation
*/
export async function apiPostDatabase(
page: Page,
requestBody: DatabaseCreatePayload,
): 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);
}

View File

@@ -0,0 +1,133 @@
/**
* 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, 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 = rison.encode(filter);
// Use failOnStatusCode: false so we return null instead of throwing on errors
const response = await apiGet(page, `${ENDPOINTS.DATASET}?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 DatasetResult;
}
return null;
}
/**
* GET request to fetch a dataset's details
* @param page - Playwright page instance (provides authentication context)
* @param datasetId - ID of the dataset to fetch
* @returns API response with dataset details
*/
export async function apiGetDataset(
page: Page,
datasetId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiGet(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
}
/**
* 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);
}

View File

@@ -0,0 +1,193 @@
/**
* 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/)
* Normalizes to always end with '/' for consistent URL resolution
*/
function getBaseUrl(): string {
// Use environment variable which includes path prefix if configured
// Normalize to always end with '/' (matches playwright.config.ts normalization)
const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
return url.endsWith('/') ? url : `${url}/`;
}
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.',
);
}
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,
});
}

View File

@@ -17,9 +17,10 @@
* under the License.
*/
import { Page, Response } from '@playwright/test';
import { Page, Response, Cookie } from '@playwright/test';
import { Form } from '../components/core';
import { URL } from '../utils/urls';
import { TIMEOUT } from '../utils/constants';
export class AuthPage {
private readonly page: Page;
@@ -56,7 +57,7 @@ export class AuthPage {
* Wait for login form to be visible
*/
async waitForLoginForm(): Promise<void> {
await this.loginForm.waitForVisible({ timeout: 5000 });
await this.loginForm.waitForVisible({ timeout: TIMEOUT.FORM_LOAD });
}
/**
@@ -83,6 +84,67 @@ export class AuthPage {
await loginButton.click();
}
/**
* Wait for successful login by verifying the login response and session cookie.
* Call this after loginWithCredentials to ensure authentication completed.
*
* This does NOT assume a specific landing page (which is configurable).
* Instead it:
* 1. Checks if session cookie already exists (guards against race condition)
* 2. Waits for POST /login/ response with redirect status
* 3. Polls for session cookie to appear
*
* @param options - Optional wait options
*/
async waitForLoginSuccess(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
const startTime = Date.now();
// 1. Guard: Check if session cookie already exists (race condition protection)
const existingCookie = await this.getSessionCookie();
if (existingCookie?.value) {
// Already authenticated - login completed before we started waiting
return;
}
// 2. Wait for POST /login/ response (bounded by caller's timeout)
const loginResponse = await this.page.waitForResponse(
response =>
response.url().includes('/login/') &&
response.request().method() === 'POST',
{ timeout },
);
// 3. Verify it's a redirect (3xx status code indicates successful login)
const status = loginResponse.status();
if (status < 300 || status >= 400) {
throw new Error(`Login failed: expected redirect (3xx), got ${status}`);
}
// 4. Poll for session cookie to appear (HttpOnly cookie, not accessible via document.cookie)
// Use page.context().cookies() since session cookie is HttpOnly
const pollInterval = 500; // 500ms instead of 100ms for less chattiness
while (true) {
const remaining = timeout - (Date.now() - startTime);
if (remaining <= 0) {
break; // Timeout exceeded
}
const sessionCookie = await this.getSessionCookie();
if (sessionCookie && sessionCookie.value) {
// Success - session cookie has landed
return;
}
await this.page.waitForTimeout(Math.min(pollInterval, remaining));
}
const currentUrl = await this.page.url();
throw new Error(
`Login timeout: session cookie did not appear within ${timeout}ms. Current URL: ${currentUrl}`,
);
}
/**
* Get current page URL
*/
@@ -93,9 +155,9 @@ export class AuthPage {
/**
* Get the session cookie specifically
*/
async getSessionCookie(): Promise<{ name: string; value: string } | null> {
async getSessionCookie(): Promise<Cookie | null> {
const cookies = await this.page.context().cookies();
return cookies.find((c: any) => c.name === 'session') || null;
return cookies.find(c => c.name === 'session') || null;
}
/**
@@ -106,7 +168,7 @@ export class AuthPage {
selector => this.page.locator(selector).isVisible(),
);
const visibilityResults = await Promise.all(visibilityPromises);
return visibilityResults.some((isVisible: any) => isVisible);
return visibilityResults.some(isVisible => isVisible);
}
/**
@@ -114,7 +176,7 @@ export class AuthPage {
*/
async waitForLoginRequest(): Promise<Response> {
return this.page.waitForResponse(
(response: any) =>
response =>
response.url().includes('/login/') &&
response.request().method() === 'POST',
);

View File

@@ -0,0 +1,115 @@
/**
* 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 { URL } from '../utils/urls';
/**
* Dataset List Page object.
*/
export class DatasetListPage {
private readonly page: Page;
private readonly table: Table;
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) {
this.page = page;
this.table = new Table(page);
}
/**
* Navigate to the dataset list page
*/
async goto(): Promise<void> {
await this.page.goto(URL.DATASET_LIST);
}
/**
* 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 dataset row locator by name.
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
*
* @param datasetName - The name of the dataset
* @returns Locator for the dataset row
*
* @example
* await expect(datasetListPage.getDatasetRow('birth_names')).toBeVisible();
*/
getDatasetRow(datasetName: string): Locator {
return this.table.getRow(datasetName);
}
/**
* Clicks on a dataset name to navigate to Explore
* @param datasetName - The name of the dataset to click
*/
async clickDatasetName(datasetName: string): Promise<void> {
await this.table.clickRowLink(
datasetName,
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,
);
}
}

View File

@@ -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 { Page, Locator } from '@playwright/test';
import { TIMEOUT } from '../utils/constants';
/**
* Explore Page object
*/
export class ExplorePage {
private readonly page: Page;
private static readonly SELECTORS = {
DATASOURCE_CONTROL: '[data-test="datasource-control"]',
VIZ_SWITCHER: '[data-test="fast-viz-switcher"]',
} as const;
constructor(page: Page) {
this.page = page;
}
/**
* Waits for the Explore page to load.
* Validates URL contains /explore/ and datasource control is visible.
*
* @param options - Optional wait options
*/
async waitForPageLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
await this.page.waitForURL('**/explore/**', { timeout });
await this.page.waitForSelector(ExplorePage.SELECTORS.DATASOURCE_CONTROL, {
state: 'visible',
timeout,
});
}
/**
* Gets the datasource control locator.
* Returns a Locator that tests can use with expect() or to read text.
*
* @returns Locator for the datasource control
*
* @example
* const name = await explorePage.getDatasourceControl().textContent();
*/
getDatasourceControl(): Locator {
return this.page.locator(ExplorePage.SELECTORS.DATASOURCE_CONTROL);
}
/**
* Gets the currently selected dataset name from the datasource control
*/
async getDatasetName(): Promise<string> {
const text = await this.getDatasourceControl().textContent();
return text?.trim() || '';
}
/**
* Gets the visualization switcher locator.
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
*
* @returns Locator for the viz switcher
*
* @example
* await expect(explorePage.getVizSwitcher()).toBeVisible();
*/
getVizSwitcher(): Locator {
return this.page.locator(ExplorePage.SELECTORS.VIZ_SWITCHER);
}
}

View File

@@ -20,69 +20,74 @@
import { test, expect } from '@playwright/test';
import { AuthPage } from '../../pages/AuthPage';
import { URL } from '../../utils/urls';
import { TIMEOUT } from '../../utils/constants';
test.describe('Login view', () => {
let authPage: AuthPage;
// Test credentials - can be overridden via environment variables
const adminUsername = process.env.PLAYWRIGHT_ADMIN_USERNAME || 'admin';
const adminPassword = process.env.PLAYWRIGHT_ADMIN_PASSWORD || 'general';
test.beforeEach(async ({ page }: any) => {
authPage = new AuthPage(page);
await authPage.goto();
await authPage.waitForLoginForm();
});
/**
* Auth/login tests use per-test navigation via beforeEach.
* Each test starts fresh on the login page without global authentication.
* This follows the Cypress pattern for auth testing - simple and isolated.
*/
test('should redirect to login with incorrect username and password', async ({
page,
}: any) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
let authPage: AuthPage;
// Attempt login with incorrect credentials
await authPage.loginWithCredentials('admin', 'wrongpassword');
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Failed login returns 401 Unauthorized or 302 redirect to login
expect([401, 302]).toContain(loginResponse.status());
// Wait for redirect to complete before checking URL
await page.waitForURL((url: any) => url.pathname.endsWith('login/'), {
timeout: 10000,
});
// Verify we stay on login page
const currentUrl = await authPage.getCurrentUrl();
expect(currentUrl).toContain(URL.LOGIN);
// Verify error message is shown
const hasError = await authPage.hasLoginError();
expect(hasError).toBe(true);
});
test('should login with correct username and password', async ({
page,
}: any) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
// Login with correct credentials
await authPage.loginWithCredentials('admin', 'general');
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Successful login returns 302 redirect
expect(loginResponse.status()).toBe(302);
// Wait for successful redirect to welcome page
await page.waitForURL(
(url: any) => url.pathname.endsWith('superset/welcome/'),
{
timeout: 10000,
},
);
// Verify specific session cookie exists
const sessionCookie = await authPage.getSessionCookie();
expect(sessionCookie).not.toBeNull();
expect(sessionCookie?.value).toBeTruthy();
});
test.beforeEach(async ({ page }) => {
// Navigate to login page before each test (ensures clean state)
authPage = new AuthPage(page);
await authPage.goto();
await authPage.waitForLoginForm();
});
test('should redirect to login with incorrect username and password', async ({
page,
}) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
// Attempt login with incorrect credentials (both username and password invalid)
await authPage.loginWithCredentials('wronguser', 'wrongpassword');
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Failed login returns 401 Unauthorized or 302 redirect to login
expect([401, 302]).toContain(loginResponse.status());
// Wait for redirect to complete before checking URL
await page.waitForURL(url => url.pathname.endsWith(URL.LOGIN), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Verify we stay on login page
const currentUrl = await authPage.getCurrentUrl();
expect(currentUrl).toContain(URL.LOGIN);
// Verify error message is shown
const hasError = await authPage.hasLoginError();
expect(hasError).toBe(true);
});
test('should login with correct username and password', async ({ page }) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
// Login with correct credentials
await authPage.loginWithCredentials(adminUsername, adminPassword);
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Successful login returns 302 redirect
expect(loginResponse.status()).toBe(302);
// Wait for successful redirect to welcome page
await page.waitForURL(url => url.pathname.endsWith(URL.WELCOME), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Verify specific session cookie exists
const sessionCookie = await authPage.getSessionCookie();
expect(sessionCookie).not.toBeNull();
expect(sessionCookie?.value).toBeTruthy();
});

View File

@@ -19,52 +19,98 @@ under the License.
# Experimental Playwright Tests
This directory contains Playwright tests that are still under development or validation.
## Purpose
Tests in this directory run in "shadow mode" with `continue-on-error: true` in CI:
- Failures do NOT block PR merges
- Allows tests to run in CI to validate stability before promotion
- Provides visibility into test reliability over time
This directory contains **experimental** Playwright E2E tests that are being developed and stabilized before becoming part of the required test suite.
## Promoting Tests to Stable
## How Experimental Tests Work
Once a test has proven stable (no false positives/negatives over sufficient time):
1. Move the test file out of `experimental/` to the appropriate feature directory:
```bash
# From the repository root:
git mv superset-frontend/playwright/tests/experimental/dashboard/test.spec.ts \
superset-frontend/playwright/tests/dashboard/
# Or from the superset-frontend/ directory:
git mv playwright/tests/experimental/dashboard/test.spec.ts \
playwright/tests/dashboard/
```
2. The test will automatically become required for merge
## Test Organization
Organize tests by feature area:
- `auth/` - Authentication and authorization tests
- `dashboard/` - Dashboard functionality tests
- `explore/` - Chart builder tests
- `sqllab/` - SQL Lab tests
- etc.
## Running Tests
### Running Tests
**By default (CI and local), experimental tests are EXCLUDED:**
```bash
# Run all experimental tests (requires INCLUDE_EXPERIMENTAL env var)
INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/
# Run specific experimental test
INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/dashboard/test.spec.ts
# Run in UI mode for debugging
INCLUDE_EXPERIMENTAL=true npm run playwright:ui -- experimental/
npm run playwright:test
# Only runs stable tests (tests/auth/*)
```
**Note**: The `INCLUDE_EXPERIMENTAL=true` environment variable is required because experimental tests are filtered out by default in `playwright.config.ts`. Without it, Playwright will report "No tests found".
**To include experimental tests, set the environment variable:**
```bash
INCLUDE_EXPERIMENTAL=true npm run playwright:test
# Runs all tests including experimental/
```
### CI Behavior
- **Required CI jobs**: Experimental tests are excluded by default
- Tests in `experimental/` do NOT block merges
- Failures in `experimental/` do NOT fail the build
- **Experimental CI jobs** (optional): Use `TEST_PATH=experimental/`
- Set `INCLUDE_EXPERIMENTAL=true` in the job environment to include experimental tests
- These jobs can use `continue-on-error: true` for shadow mode
### Configuration
The experimental pattern is configured in `playwright.config.ts`:
```typescript
testIgnore: process.env.INCLUDE_EXPERIMENTAL
? undefined
: '**/experimental/**',
```
This ensures:
- Without `INCLUDE_EXPERIMENTAL`: Tests in `experimental/` are ignored
- With `INCLUDE_EXPERIMENTAL=true`: All tests run, including experimental
## When to Use Experimental
Add tests to `experimental/` when:
1. **Testing new infrastructure** - New page objects, components, or patterns that need real-world validation
2. **Flaky tests** - Tests that pass locally but have intermittent CI failures that need investigation
3. **New test types** - E2E tests for new features that need to prove stability before becoming required
4. **Prototyping** - Experimental approaches that may or may not become standard patterns
## Moving Tests to Stable
Once an experimental test has proven stable (consistent CI passes over time):
1. **Move the test file** from `experimental/` to the appropriate stable directory:
```bash
git mv tests/experimental/dataset/my-test.spec.ts tests/dataset/my-test.spec.ts
```
2. **Commit the move** with a clear message:
```bash
git commit -m "test(playwright): promote my-test from experimental to stable"
```
3. **Test will now be required** - It will run by default and block merges on failure
## Current Experimental Tests
### Dataset Tests
- **`dataset/dataset-list.spec.ts`** - Dataset list E2E tests
- Status: Infrastructure complete, validating stability
- Includes: Delete dataset test with API-based test data
- Supporting infrastructure: API helpers, Modal components, page objects
## Infrastructure Location
**Important**: Supporting infrastructure (components, page objects, API helpers) should live in **stable locations**, NOT under `experimental/`:
✅ **Correct locations:**
- `playwright/components/` - Components used by any tests
- `playwright/pages/` - Page objects for any features
- `playwright/helpers/api/` - API helpers for test data setup
❌ **Avoid:**
- `playwright/tests/experimental/components/` - Makes it hard to share infrastructure
This keeps infrastructure reusable and avoids duplication when tests graduate from experimental to stable.
## Questions?
See [Superset Testing Documentation](https://superset.apache.org/docs/contributing/development#testing) or ask in the `#testing` Slack channel.

View File

@@ -0,0 +1,254 @@
/**
* 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.
*
* DEPENDENCY: Tests assume the example dataset exists and is a virtual dataset.
* If examples aren't loaded or the dataset changes, tests will fail.
* This is acceptable for experimental tests; stable tests should use dedicated
* seeded test data to decouple from example data changes.
*/
const TEST_DATASETS = {
EXAMPLE_DATASET: 'members_channels_2',
} as const;
/**
* Dataset List E2E Tests
*
* Uses flat test() structure per project convention (matches login.spec.ts).
* Shared state and hooks are at file scope.
*/
// File-scope state (reset in beforeEach)
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');
// Verify dataset is removed from list
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
});
test('should duplicate a dataset with new name', async ({ page }) => {
// Use virtual example dataset
const originalName = TEST_DATASETS.EXAMPLE_DATASET;
const duplicateName = `duplicate_${originalName}_${Date.now()}`;
// Get the dataset by name (ID varies by environment)
const original = await getDatasetByName(page, originalName);
expect(original).not.toBeNull();
expect(original!.id).toBeGreaterThan(0);
// 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(duplicateName);
// Click the Duplicate button
await duplicateModal.clickDuplicate();
// Get the duplicate dataset ID from response
const duplicateResponse = await duplicateResponsePromise;
const duplicateData = await duplicateResponse.json();
const duplicateId = duplicateData.id;
// Track duplicate for cleanup (original is example data, don't delete it)
testResources = { datasetIds: [duplicateId] };
// Modal should close
await duplicateModal.waitForHidden();
// Note: Duplicate action does not show a success toast (only errors)
// Verification is done via API and UI list check below
// Refresh to see the duplicated dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets exist in list
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
// API Verification: Compare original and duplicate datasets
const duplicateResponseData = await apiGetDataset(page, duplicateId);
const duplicateDataFull = await duplicateResponseData.json();
// Verify key properties were copied correctly (original data already fetched)
expect(duplicateDataFull.result.sql).toBe(original!.sql);
expect(duplicateDataFull.result.database.id).toBe(original!.database.id);
expect(duplicateDataFull.result.schema).toBe(original!.schema);
// Name should be different (the duplicate name)
expect(duplicateDataFull.result.table_name).toBe(duplicateName);
});

View File

@@ -0,0 +1,46 @@
/**
* 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.
*/
/**
* Timeout constants for Playwright tests.
* Only define timeouts that differ from Playwright defaults or are semantically important.
*
* Default Playwright timeouts (from playwright.config.ts):
* - Test timeout: 30000ms (30s)
* - Expect timeout: 8000ms (8s)
*
* Use these constants instead of magic numbers for better maintainability.
*/
export const TIMEOUT = {
/**
* Global setup timeout (matches test timeout for cold CI starts)
*/
GLOBAL_SETUP: 30000, // 30s for global setup auth
/**
* Page navigation and load timeouts
*/
PAGE_LOAD: 10000, // 10s for page transitions (login → welcome, dataset → explore)
/**
* Form and UI element load timeouts
*/
FORM_LOAD: 5000, // 5s for forms to become visible (login form, modals)
} as const;

View File

@@ -17,7 +17,18 @@
* under the License.
*/
/**
* URL constants for Playwright navigation
*
* These are relative paths (no leading '/') that rely on baseURL ending with '/'.
* playwright.config.ts normalizes baseURL to always end with '/' to ensure
* correct URL resolution with APP_PREFIX (e.g., /app/prefix/).
*
* Example: baseURL='http://localhost:8088/app/prefix/' + 'tablemodelview/list'
* = 'http://localhost:8088/app/prefix/tablemodelview/list'
*/
export const URL = {
DATASET_LIST: 'tablemodelview/list',
LOGIN: 'login/',
WELCOME: 'superset/welcome/',
} as const;