mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
test(playwright): convert and create new dataset list playwright tests (#36196)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
13
.github/workflows/bashlib.sh
vendored
13
.github/workflows/bashlib.sh
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/superset-e2e.yml
vendored
2
.github/workflows/superset-e2e.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/superset-playwright.yml
vendored
2
.github/workflows/superset-playwright.yml
vendored
@@ -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
|
||||
|
||||
3
superset-frontend/.gitignore
vendored
3
superset-frontend/.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
coverage/*
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
playwright/.auth
|
||||
playwright-report/
|
||||
test-results/
|
||||
src/temp
|
||||
.temp_cache/
|
||||
.tsbuildinfo
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
118
superset-frontend/playwright/components/core/Modal.ts
Normal file
118
superset-frontend/playwright/components/core/Modal.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
102
superset-frontend/playwright/components/core/Table.ts
Normal file
102
superset-frontend/playwright/components/core/Table.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
105
superset-frontend/playwright/components/core/Toast.ts
Normal file
105
superset-frontend/playwright/components/core/Toast.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,5 @@
|
||||
export { Button } from './Button';
|
||||
export { Form } from './Form';
|
||||
export { Input } from './Input';
|
||||
export { Modal } from './Modal';
|
||||
export { Table } from './Table';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
22
superset-frontend/playwright/components/modals/index.ts
Normal file
22
superset-frontend/playwright/components/modals/index.ts
Normal 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';
|
||||
93
superset-frontend/playwright/global-setup.ts
Normal file
93
superset-frontend/playwright/global-setup.ts
Normal 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;
|
||||
79
superset-frontend/playwright/helpers/api/database.ts
Normal file
79
superset-frontend/playwright/helpers/api/database.ts
Normal 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);
|
||||
}
|
||||
133
superset-frontend/playwright/helpers/api/dataset.ts
Normal file
133
superset-frontend/playwright/helpers/api/dataset.ts
Normal 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);
|
||||
}
|
||||
193
superset-frontend/playwright/helpers/api/requests.ts
Normal file
193
superset-frontend/playwright/helpers/api/requests.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
115
superset-frontend/playwright/pages/DatasetListPage.ts
Normal file
115
superset-frontend/playwright/pages/DatasetListPage.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
88
superset-frontend/playwright/pages/ExplorePage.ts
Normal file
88
superset-frontend/playwright/pages/ExplorePage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
46
superset-frontend/playwright/utils/constants.ts
Normal file
46
superset-frontend/playwright/utils/constants.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user