test(playwright): additional dataset list playwright tests (#36684)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Joe Li
2026-02-05 16:42:07 -08:00
committed by GitHub
parent ef4f7afa90
commit 5040db859c
30 changed files with 2828 additions and 125 deletions

View File

@@ -206,6 +206,7 @@
"@types/rison": "0.1.0",
"@types/sinon": "^17.0.3",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"babel-jest": "^30.0.2",
@@ -279,6 +280,7 @@
"tscw-config": "^1.1.2",
"tsx": "^4.21.0",
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.3",
"webpack": "^5.105.0",
@@ -20401,6 +20403,16 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/@types/unzipper": {
"version": "0.10.11",
"resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.11.tgz",
"integrity": "sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/urijs": {
"version": "1.19.26",
"resolved": "https://registry.npmjs.org/@types/urijs/-/urijs-1.19.26.tgz",
@@ -57845,6 +57857,20 @@
"node": ">=8"
}
},
"node_modules/unzipper": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bluebird": "~3.7.2",
"duplexer2": "~0.1.4",
"fs-extra": "^11.2.0",
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
}
},
"node_modules/upath": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",

View File

@@ -288,6 +288,7 @@
"@types/rison": "0.1.0",
"@types/sinon": "^17.0.3",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"babel-jest": "^30.0.2",
@@ -361,6 +362,7 @@
"tscw-config": "^1.1.2",
"tsx": "^4.21.0",
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.3",
"webpack": "^5.105.0",

View File

@@ -74,6 +74,9 @@ export default defineConfig({
viewport: { width: 1280, height: 1024 },
// Accept downloads without prompts (needed for export tests)
acceptDownloads: true,
// Screenshots and videos on failure
screenshot: 'only-on-failure',
video: 'retain-on-failure',
@@ -117,10 +120,19 @@ export default defineConfig({
// Web server setup - disabled in CI (Flask started separately in workflow)
webServer: process.env.CI
? undefined
: {
command: 'curl -f http://localhost:8088/health',
url: 'http://localhost:8088/health',
reuseExistingServer: true,
timeout: 5000,
},
: (() => {
// Support custom base URL (e.g., http://localhost:9012/app/prefix/)
const baseUrl =
process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
// Extract origin (scheme + host + port) for health check
// Health endpoint is always at /health regardless of app prefix
const healthUrl = new URL('/health', new URL(baseUrl).origin).href;
return {
// Quote URL to prevent shell injection via PLAYWRIGHT_BASE_URL
command: `curl -f '${healthUrl}'`,
url: healthUrl,
reuseExistingServer: true,
timeout: 5000,
};
})(),
});

View File

@@ -0,0 +1,116 @@
/**
* 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';
import { Button, Checkbox, Table } from '../core';
const BULK_SELECT_SELECTORS = {
CONTROLS: '[data-test="bulk-select-controls"]',
ACTION: '[data-test="bulk-select-action"]',
} as const;
/**
* BulkSelect component for Superset ListView bulk operations.
* Provides a reusable interface for bulk selection and actions across list pages.
*
* @example
* const bulkSelect = new BulkSelect(page, table);
* await bulkSelect.enable();
* await bulkSelect.selectRow('my-dataset');
* await bulkSelect.selectRow('another-dataset');
* await bulkSelect.clickAction('Delete');
*/
export class BulkSelect {
private readonly page: Page;
private readonly table: Table;
constructor(page: Page, table: Table) {
this.page = page;
this.table = table;
}
/**
* Gets the "Bulk select" toggle button
*/
getToggleButton(): Button {
return new Button(
this.page,
this.page.getByRole('button', { name: 'Bulk select' }),
);
}
/**
* Enables bulk selection mode by clicking the toggle button
*/
async enable(): Promise<void> {
await this.getToggleButton().click();
}
/**
* Gets the checkbox for a row by name
* @param rowName - The name/text identifying the row
*/
getRowCheckbox(rowName: string): Checkbox {
const row = this.table.getRow(rowName);
return new Checkbox(this.page, row.getByRole('checkbox'));
}
/**
* Selects a row's checkbox in bulk select mode
* @param rowName - The name/text identifying the row to select
*/
async selectRow(rowName: string): Promise<void> {
await this.getRowCheckbox(rowName).check();
}
/**
* Deselects a row's checkbox in bulk select mode
* @param rowName - The name/text identifying the row to deselect
*/
async deselectRow(rowName: string): Promise<void> {
await this.getRowCheckbox(rowName).uncheck();
}
/**
* Gets the bulk select controls container locator (for assertions)
*/
getControls(): Locator {
return this.page.locator(BULK_SELECT_SELECTORS.CONTROLS);
}
/**
* Gets a bulk action button by name
* @param actionName - The name of the bulk action (e.g., "Export", "Delete")
*/
getActionButton(actionName: string): Button {
const controls = this.getControls();
return new Button(
this.page,
controls.locator(BULK_SELECT_SELECTORS.ACTION, { hasText: actionName }),
);
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* @param actionName - The name of the bulk action to click
*/
async clickAction(actionName: string): Promise<void> {
await this.getActionButton(actionName).click();
}
}

View File

@@ -0,0 +1,21 @@
/**
* 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.
*/
// ListView-specific Playwright Components for Superset
export { BulkSelect } from './BulkSelect';

View File

@@ -0,0 +1,207 @@
/**
* 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';
const ACE_EDITOR_SELECTORS = {
TEXT_INPUT: '.ace_text-input',
TEXT_LAYER: '.ace_text-layer',
CONTENT: '.ace_content',
SCROLLER: '.ace_scroller',
} as const;
/**
* AceEditor component for interacting with Ace Editor instances in Playwright.
* Uses the ace editor API directly for reliable text manipulation.
*/
export class AceEditor {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Gets the editor element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Waits for the ace editor to be fully loaded and ready for interaction.
*/
async waitForReady(): Promise<void> {
// Wait for editor to be attached (outer .ace_editor div may be CSS-hidden)
await this.locator.waitFor({ state: 'attached' });
await this.locator
.locator(ACE_EDITOR_SELECTORS.CONTENT)
.waitFor({ state: 'attached' });
// Wait for window.ace library to be fully loaded (may load async)
await this.page.waitForFunction(
() =>
typeof (window as unknown as { ace?: { edit?: unknown } }).ace?.edit ===
'function',
{ timeout: 10000 },
);
}
/**
* Sets text in the ace editor using the ace API.
* Uses element handle to target the specific editor instance (not global ID lookup).
* @param text - The text to set
*/
async setText(text: string): Promise<void> {
await this.waitForReady();
const elementHandle = await this.locator.elementHandle();
if (!elementHandle) {
throw new Error('Could not get element handle for ace editor');
}
await this.page.evaluate(
({ element, value }) => {
const windowWithAce = window as unknown as {
ace?: {
edit(el: Element): {
setValue(v: string, c: number): void;
session: { getUndoManager(): { reset(): void } };
};
};
};
if (!windowWithAce.ace) {
throw new Error(
'Ace editor library not loaded. Ensure the page has finished loading.',
);
}
// ace.edit() accepts either an element ID string or the DOM element itself
const editor = windowWithAce.ace.edit(element);
editor.setValue(value, 1);
editor.session.getUndoManager().reset();
},
{ element: elementHandle, value: text },
);
}
/**
* Gets the text content from the ace editor.
* Uses element handle to target the specific editor instance.
* @returns The text content
*/
async getText(): Promise<string> {
await this.waitForReady();
const elementHandle = await this.locator.elementHandle();
if (!elementHandle) {
throw new Error('Could not get element handle for ace editor');
}
return this.page.evaluate(element => {
const windowWithAce = window as unknown as {
ace?: { edit(el: Element): { getValue(): string } };
};
if (!windowWithAce.ace) {
throw new Error(
'Ace editor library not loaded. Ensure the page has finished loading.',
);
}
return windowWithAce.ace.edit(element).getValue();
}, elementHandle);
}
/**
* Clears the text in the ace editor.
*/
async clear(): Promise<void> {
await this.setText('');
}
/**
* Appends text to the existing content in the ace editor.
* Uses element handle to target the specific editor instance.
* @param text - The text to append
*/
async appendText(text: string): Promise<void> {
await this.waitForReady();
const elementHandle = await this.locator.elementHandle();
if (!elementHandle) {
throw new Error('Could not get element handle for ace editor');
}
await this.page.evaluate(
({ element, value }) => {
const windowWithAce = window as unknown as {
ace?: {
edit(el: Element): {
getValue(): string;
setValue(v: string, c: number): void;
};
};
};
if (!windowWithAce.ace) {
throw new Error(
'Ace editor library not loaded. Ensure the page has finished loading.',
);
}
const editor = windowWithAce.ace.edit(element);
const currentText = editor.getValue();
// Only add newline if there's existing text that doesn't already end with one
const needsNewline = currentText && !currentText.endsWith('\n');
const newText = currentText + (needsNewline ? '\n' : '') + value;
editor.setValue(newText, 1);
},
{ element: elementHandle, value: text },
);
}
/**
* Focuses the ace editor.
* Uses element handle to target the specific editor instance.
*/
async focus(): Promise<void> {
await this.waitForReady();
const elementHandle = await this.locator.elementHandle();
if (!elementHandle) {
throw new Error('Could not get element handle for ace editor');
}
await this.page.evaluate(element => {
const windowWithAce = window as unknown as {
ace?: { edit(el: Element): { focus(): void } };
};
if (!windowWithAce.ace) {
throw new Error(
'Ace editor library not loaded. Ensure the page has finished loading.',
);
}
windowWithAce.ace.edit(element).focus();
}, elementHandle);
}
/**
* Checks if the editor is visible.
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
}

View File

@@ -0,0 +1,95 @@
/**
* 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';
/**
* Core Checkbox component used in Playwright tests to interact with checkbox
* elements in the Superset UI.
*
* This class wraps a Playwright {@link Locator} pointing to a checkbox input
* and provides convenience methods for common interactions such as checking,
* unchecking, toggling, and asserting checkbox state and visibility.
*
* @example
* const checkbox = new Checkbox(page, page.locator('input[type="checkbox"]'));
* await checkbox.check();
* await expect(await checkbox.isChecked()).toBe(true);
*
* @param page - The Playwright {@link Page} instance associated with the test.
* @param locator - The Playwright {@link Locator} targeting the checkbox element.
*/
export class Checkbox {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, locator: Locator) {
this.page = page;
this.locator = locator;
}
/**
* Gets the checkbox element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Checks the checkbox (ensures it's checked)
*/
async check(): Promise<void> {
await this.locator.check();
}
/**
* Unchecks the checkbox (ensures it's unchecked)
*/
async uncheck(): Promise<void> {
await this.locator.uncheck();
}
/**
* Toggles the checkbox state
*/
async toggle(): Promise<void> {
await this.locator.click();
}
/**
* Checks if the checkbox is checked
*/
async isChecked(): Promise<boolean> {
return this.locator.isChecked();
}
/**
* Checks if the checkbox is visible
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
/**
* Checks if the checkbox is enabled
*/
async isEnabled(): Promise<boolean> {
return this.locator.isEnabled();
}
}

View File

@@ -0,0 +1,187 @@
/**
* 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';
/**
* Ant Design Select component selectors
*/
const SELECT_SELECTORS = {
DROPDOWN: '.ant-select-dropdown',
OPTION: '.ant-select-item-option',
SEARCH_INPUT: '.ant-select-selection-search-input',
CLEAR: '.ant-select-clear',
} as const;
/**
* Select component for Ant Design Select/Combobox interactions.
*/
export class Select {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Creates a Select from a combobox role with the given accessible name
* @param page - The Playwright page
* @param name - The accessible name (aria-label or placeholder text)
*/
static fromRole(page: Page, name: string): Select {
const locator = page.getByRole('combobox', { name });
return new Select(page, locator);
}
/**
* Gets the select element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Opens the dropdown, types to filter, and selects an option.
* Handles cases where the option may not be initially visible in the dropdown.
* Waits for dropdown to close after selection to avoid stale dropdowns.
* @param optionText - The text of the option to select
*/
async selectOption(optionText: string): Promise<void> {
await this.open();
await this.type(optionText);
await this.clickOption(optionText);
// Wait for dropdown to close to avoid multiple visible dropdowns
await this.waitForDropdownClose();
}
/**
* Waits for dropdown to close after selection
* This prevents strict mode violations when multiple selects are used sequentially
*/
private async waitForDropdownClose(): Promise<void> {
// Wait for dropdown to actually close (become hidden)
await this.page
.locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
.last()
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(error => {
// Only ignore TimeoutError (dropdown may already be closed); re-throw others
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
});
}
/**
* Opens the dropdown
*/
async open(): Promise<void> {
await this.locator.click();
}
/**
* Clicks an option in an already-open dropdown by its text content.
* Uses selector-based approach matching Cypress patterns.
* Handles multiple dropdowns by targeting only visible, non-hidden ones.
* @param optionText - The text of the option to click (partial match for filtered results)
*/
async clickOption(optionText: string): Promise<void> {
// Target visible dropdown (excludes hidden ones via :not(.ant-select-dropdown-hidden))
// Use .last() in case multiple dropdowns exist - the most recent one is what we want
const dropdown = this.page
.locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
.last();
await dropdown.waitFor({ state: 'visible' });
// Find option by text content - use partial match since filtered results may have prefixes
// (e.g., searching for 'main' shows 'examples.main', 'system.main')
// First try exact match, fall back to partial match
const exactOption = dropdown
.locator(SELECT_SELECTORS.OPTION)
.getByText(optionText, { exact: true });
if ((await exactOption.count()) > 0) {
await exactOption.click();
} else {
// Fall back to first option containing the text
const partialOption = dropdown
.locator(SELECT_SELECTORS.OPTION)
.filter({ hasText: optionText })
.first();
await partialOption.click();
}
}
/**
* Closes the dropdown by pressing Escape
*/
async close(): Promise<void> {
await this.page.keyboard.press('Escape');
}
/**
* Types into the select to filter options (assumes dropdown is open)
* @param text - The text to type
*/
async type(text: string): Promise<void> {
// Find the actual search input inside the select component
const searchInput = this.locator.locator(SELECT_SELECTORS.SEARCH_INPUT);
try {
// Wait for search input in case dropdown is still rendering
await searchInput.first().waitFor({ state: 'attached', timeout: 1000 });
await searchInput.first().fill(text);
} catch (error) {
// Only handle TimeoutError (search input not found); re-throw other errors
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
// Fallback: locator might be the input itself (e.g., from getByRole('combobox'))
await this.locator.fill(text);
}
}
/**
* Clears the current selection
*/
async clear(): Promise<void> {
await this.locator.clear();
}
/**
* Checks if the select is visible
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
/**
* Checks if the select is enabled
*/
async isEnabled(): Promise<boolean> {
return this.locator.isEnabled();
}
}

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 { Locator, Page } from '@playwright/test';
/**
* Tabs component for Ant Design tab navigation.
*/
export class Tabs {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, locator?: Locator) {
this.page = page;
// Default to the tablist role if no specific locator provided
this.locator = locator ?? page.getByRole('tablist');
}
/**
* Gets the tablist element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Gets a tab by name, scoped to this tablist's container
* @param tabName - The name/label of the tab
*/
getTab(tabName: string): Locator {
return this.locator.getByRole('tab', { name: tabName });
}
/**
* Clicks a tab by name
* @param tabName - The name/label of the tab to click
*/
async clickTab(tabName: string): Promise<void> {
await this.getTab(tabName).click();
}
/**
* Gets the tab panel content for a given tab
* @param tabName - The name/label of the tab
*/
getTabPanel(tabName: string): Locator {
return this.page.getByRole('tabpanel', { name: tabName });
}
/**
* Checks if a tab is selected
* @param tabName - The name/label of the tab
*/
async isSelected(tabName: string): Promise<boolean> {
const tab = this.getTab(tabName);
const ariaSelected = await tab.getAttribute('aria-selected');
return ariaSelected === 'true';
}
}

View File

@@ -0,0 +1,109 @@
/**
* 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';
/**
* Playwright helper for interacting with HTML {@link HTMLTextAreaElement | `<textarea>`} elements.
*
* This component wraps a Playwright {@link Locator} and provides convenience methods for
* filling, clearing, and reading the value of a textarea without having to work with
* locators directly.
*
* Typical usage:
* ```ts
* const textarea = new Textarea(page, 'textarea[name="description"]');
* await textarea.fill('Some multi-line text');
* const value = await textarea.getValue();
* ```
*
* You can also construct an instance from the `name` attribute:
* ```ts
* const textarea = Textarea.fromName(page, 'description');
* await textarea.clear();
* ```
*/
export class Textarea {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Creates a Textarea from a name attribute
* @param page - The Playwright page
* @param name - The name attribute value
*/
static fromName(page: Page, name: string): Textarea {
const locator = page.locator(`textarea[name="${name}"]`);
return new Textarea(page, locator);
}
/**
* Gets the textarea element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Fills the textarea with text (clears existing content)
* @param text - The text to fill
*/
async fill(text: string): Promise<void> {
await this.locator.fill(text);
}
/**
* Clears the textarea content
*/
async clear(): Promise<void> {
await this.locator.clear();
}
/**
* Gets the current value of the textarea
*/
async getValue(): Promise<string> {
return this.locator.inputValue();
}
/**
* Checks if the textarea is visible
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
/**
* Checks if the textarea is enabled
*/
async isEnabled(): Promise<boolean> {
return this.locator.isEnabled();
}
}

View File

@@ -18,10 +18,15 @@
*/
// Core Playwright Components for Superset
export { AceEditor } from './AceEditor';
export { Button } from './Button';
export { Checkbox } from './Checkbox';
export { Form } from './Form';
export { Input } from './Input';
export { Menu } from './Menu';
export { Modal } from './Modal';
export { Select } from './Select';
export { Table } from './Table';
export { Tabs } from './Tabs';
export { Textarea } from './Textarea';
export { Toast } from './Toast';

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 { Page, Locator } from '@playwright/test';
import { Modal } from '../core/Modal';
/**
* Confirm Dialog component for Ant Design Modal.confirm dialogs.
* These are the "OK" / "Cancel" confirmation dialogs used throughout Superset.
* Uses getByRole with name to target specific confirm dialogs when multiple are open.
*/
export class ConfirmDialog extends Modal {
private readonly specificLocator: Locator;
constructor(page: Page, dialogName = 'Confirm save') {
super(page);
// Use getByRole with specific name to avoid strict mode violations
// when multiple dialogs are open (e.g., Edit Dataset modal + Confirm save dialog)
this.specificLocator = page.getByRole('dialog', { name: dialogName });
}
/**
* Override element getter to use specific locator
*/
override get element(): Locator {
return this.specificLocator;
}
/**
* Clicks the OK button to confirm.
* @param options.timeout - If provided, silently returns if dialog doesn't appear
* within timeout. If not provided, waits indefinitely (strict mode).
*/
async clickOk(options?: { timeout?: number }): Promise<void> {
try {
await this.element.waitFor({
state: 'visible',
timeout: options?.timeout,
});
await this.clickFooterButton('OK');
await this.waitForHidden();
} catch (error) {
// Only swallow TimeoutError when timeout was explicitly provided
if (options?.timeout !== undefined) {
if (error instanceof Error && error.name === 'TimeoutError') {
return;
}
}
throw error;
}
}
/**
* Clicks the Cancel button to dismiss
*/
async clickCancel(): Promise<void> {
await this.clickFooterButton('Cancel');
}
}

View File

@@ -55,7 +55,10 @@ export class DuplicateDatasetModal extends Modal {
datasetName: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.nameInput.fill(datasetName, options);
const input = this.nameInput.element;
// Clear existing text then fill (fill() clears first, but explicit clear is more reliable)
await input.clear();
await input.fill(datasetName, options);
}
/**

View File

@@ -0,0 +1,189 @@
/**
* 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';
import { Input, Modal, Tabs, AceEditor } from '../core';
/**
* Edit Dataset Modal component (DatasourceModal).
* Used for editing dataset properties like description, metrics, columns, etc.
* Uses specific dialog name to avoid strict mode violations when multiple dialogs are open.
*/
export class EditDatasetModal extends Modal {
private static readonly SELECTORS = {
NAME_INPUT: '[data-test="inline-name"]',
LOCK_ICON: '[data-test="lock"]',
UNLOCK_ICON: '[data-test="unlock"]',
};
private readonly tabs: Tabs;
private readonly specificLocator: Locator;
constructor(page: Page) {
super(page);
// Use getByRole with specific name to target Edit Dataset dialog
// The dialog has aria-labelledby that resolves to "edit Edit Dataset"
this.specificLocator = page.getByRole('dialog', { name: /edit.*dataset/i });
// Scope tabs to modal's tablist to avoid matching tablists elsewhere on page
this.tabs = new Tabs(page, this.specificLocator.getByRole('tablist'));
}
/**
* Override element getter to use specific locator
*/
override get element(): Locator {
return this.specificLocator;
}
/**
* Click the Save button to save changes
*/
async clickSave(): Promise<void> {
await this.clickFooterButton('Save');
}
/**
* Click the Cancel button to discard changes
*/
async clickCancel(): Promise<void> {
await this.clickFooterButton('Cancel');
}
/**
* Click the lock icon to enable edit mode
* The modal starts in read-only mode and requires clicking the lock to edit
*/
async enableEditMode(): Promise<void> {
const lockButton = this.body.locator(EditDatasetModal.SELECTORS.LOCK_ICON);
await lockButton.click();
}
/**
* Gets the dataset name input component
*/
private get nameInput(): Input {
return new Input(
this.page,
this.body.locator(EditDatasetModal.SELECTORS.NAME_INPUT),
);
}
/**
* Fill in the dataset name field
* Note: Call enableEditMode() first if the modal is in read-only mode
* @param name - The new dataset name
*/
async fillName(name: string): Promise<void> {
await this.nameInput.fill(name);
}
/**
* Navigate to a specific tab in the modal
* @param tabName - The name of the tab (e.g., 'Source', 'Metrics', 'Columns')
*/
async clickTab(tabName: string): Promise<void> {
await this.tabs.clickTab(tabName);
}
/**
* Navigate to the Settings tab
*/
async clickSettingsTab(): Promise<void> {
await this.tabs.clickTab('Settings');
}
/**
* Navigate to the Columns tab.
* Uses regex to avoid matching "Calculated columns" tab, scoped to modal.
*/
async clickColumnsTab(): Promise<void> {
// Use regex starting with "Columns" to avoid matching "Calculated columns"
// Scope to modal element to avoid matching tabs elsewhere on page
await this.element.getByRole('tab', { name: /^Columns/ }).click();
}
/**
* Gets the description Ace Editor component (Settings tab).
* The Description button and ace-editor are in the same form item.
*/
private get descriptionEditor(): AceEditor {
// Use tabpanel role with name "Settings" for more reliable lookup
const settingsPanel = this.element.getByRole('tabpanel', {
name: 'Settings',
});
// Find the form item that contains the Description button
const descriptionFormItem = settingsPanel
.locator('.ant-form-item')
.filter({
has: this.page.getByRole('button', {
name: 'Description',
exact: true,
}),
})
.first();
// The ace-editor has class .ace_editor within the form item
const editorElement = descriptionFormItem.locator('.ace_editor');
return new AceEditor(this.page, editorElement);
}
/**
* Fill the dataset description field (Settings tab).
* @param description - The description text to set
*/
async fillDescription(description: string): Promise<void> {
await this.descriptionEditor.setText(description);
}
/**
* Expand a column row by column name.
* Uses exact cell match to avoid false positives with short names like "ds".
* @param columnName - The name of the column to expand
* @returns The row locator for scoped selector access
*/
async expandColumn(columnName: string): Promise<Locator> {
// Find cell with exact column name text, then derive row from that cell
const cell = this.body.getByRole('cell', { name: columnName, exact: true });
const row = cell.locator('xpath=ancestor::tr[1]');
await row.getByRole('button', { name: /expand row/i }).click();
return row;
}
/**
* Fill column datetime format for a given column.
* Expands the column row and fills the date format input.
* Note: Expanded content appears in a sibling row, so we scope to modal body.
* @param columnName - The name of the column to edit
* @param format - The python date format string (e.g., '%Y-%m-%d')
*/
async fillColumnDateFormat(
columnName: string,
format: string,
): Promise<void> {
await this.expandColumn(columnName);
// Expanded content appears in a sibling row, not nested inside the original row.
// Use modal body scope with placeholder selector to find the datetime format input.
const dateFormatInput = new Input(
this.page,
this.body.getByPlaceholder('%Y-%m-%d'),
);
await dateFormatInput.element.waitFor({ state: 'visible' });
await dateFormatInput.clear();
await dateFormatInput.fill(format);
}
}

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';
/**
* Import dataset modal for uploading dataset export files.
* Handles file upload, overwrite confirmation, and import submission.
*/
export class ImportDatasetModal extends Modal {
private static readonly SELECTORS = {
FILE_INPUT: '[data-test="model-file-input"]',
OVERWRITE_INPUT: '[data-test="overwrite-modal-input"]',
};
/**
* Upload a file to the import modal
* @param filePath - Absolute path to the file to upload
*/
async uploadFile(filePath: string): Promise<void> {
await this.page
.locator(ImportDatasetModal.SELECTORS.FILE_INPUT)
.setInputFiles(filePath);
}
/**
* Fill the overwrite confirmation input (only needed if dataset exists)
*/
async fillOverwriteConfirmation(): Promise<void> {
const input = new Input(
this.page,
this.body.locator(ImportDatasetModal.SELECTORS.OVERWRITE_INPUT),
);
await input.fill('OVERWRITE');
}
/**
* Get the overwrite confirmation input locator
*/
getOverwriteInput() {
return this.body.locator(ImportDatasetModal.SELECTORS.OVERWRITE_INPUT);
}
/**
* Check if overwrite confirmation is visible
*/
async isOverwriteVisible(): Promise<boolean> {
return this.getOverwriteInput().isVisible();
}
/**
* Click the Import button in the footer
*/
async clickImport(): Promise<void> {
await this.clickFooterButton('Import');
}
}

View File

@@ -20,3 +20,4 @@
// Specific modal implementations
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
export { ImportDatasetModal } from './ImportDatasetModal';

View File

@@ -0,0 +1,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { Response, APIResponse } from '@playwright/test';
import { expect } from '@playwright/test';
/**
* Common interface for response types with status() method.
* Supports both Response (network interception) and APIResponse (page.request API).
*/
type ResponseLike = Response | APIResponse;
/**
* Verify response has exact status code
* @param response - Playwright Response or APIResponse object
* @param expected - Expected status code
* @returns The response for chaining
*/
export function expectStatus<T extends ResponseLike>(
response: T,
expected: number,
): T {
expect(
response.status(),
`Expected status ${expected}, got ${response.status()}`,
).toBe(expected);
return response;
}
/**
* Verify response status code is one of the expected values
* @param response - Playwright Response or APIResponse object
* @param expected - Array of acceptable status codes
* @returns The response for chaining
*/
export function expectStatusOneOf<T extends ResponseLike>(
response: T,
expected: number[],
): T {
expect(
expected,
`Expected status to be one of ${expected.join(', ')}, got ${response.status()}`,
).toContain(response.status());
return response;
}

View File

@@ -18,12 +18,33 @@
*/
import { Page, APIResponse } from '@playwright/test';
import { apiPost, apiDelete, ApiRequestOptions } from './requests';
import rison from 'rison';
import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
const ENDPOINTS = {
DATABASE: 'api/v1/database/',
} as const;
/**
* TypeScript interface for database API response
*/
export interface DatabaseResult {
id: number;
database_name: string;
/** Optional - list API masks this for security, only detail API returns it */
sqlalchemy_uri?: string;
backend?: string;
engine_information?: {
disable_ssh_tunneling?: boolean;
supports_dynamic_catalog?: boolean;
supports_file_upload?: boolean;
supports_oauth2?: boolean;
};
extra?: string;
expose_in_sqllab?: boolean;
impersonate_user?: boolean;
}
/**
* TypeScript interface for database creation API payload
* Provides compile-time safety for required fields
@@ -31,6 +52,7 @@ const ENDPOINTS = {
export interface DatabaseCreatePayload {
database_name: string;
engine: string;
sqlalchemy_uri?: string;
configuration_method?: string;
engine_information?: {
disable_ssh_tunneling?: boolean;
@@ -77,3 +99,53 @@ export async function apiDeleteDatabase(
): Promise<APIResponse> {
return apiDelete(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
}
/**
* GET request to fetch a database's details
* @param page - Playwright page instance (provides authentication context)
* @param databaseId - ID of the database to fetch
* @returns API response with database details
*/
export async function apiGetDatabase(
page: Page,
databaseId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiGet(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
}
/**
* Get a database by its name
* @param page - Playwright page instance (provides authentication context)
* @param databaseName - The database_name to search for
* @returns Database object if found, null if not found
*/
export async function getDatabaseByName(
page: Page,
databaseName: string,
): Promise<DatabaseResult | null> {
const filter = {
filters: [
{
col: 'database_name',
opr: 'eq',
value: databaseName,
},
],
};
const queryParam = rison.encode(filter);
const response = await apiGet(page, `${ENDPOINTS.DATABASE}?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 DatabaseResult;
}
return null;
}

View File

@@ -20,9 +20,13 @@
import { Page, APIResponse } from '@playwright/test';
import rison from 'rison';
import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
import { getDatabaseByName } from './database';
export const ENDPOINTS = {
DATASET: 'api/v1/dataset/',
DATASET_EXPORT: 'api/v1/dataset/export/',
DATASET_DUPLICATE: 'api/v1/dataset/duplicate',
DATASET_IMPORT: 'api/v1/dataset/import/',
} as const;
/**
@@ -37,12 +41,12 @@ export interface DatasetCreatePayload {
}
/**
* TypeScript interface for virtual dataset creation API payload
* Virtual datasets are SQL-based and support the Duplicate action in UI
* TypeScript interface for virtual dataset creation API payload.
* Virtual datasets are defined by SQL queries rather than physical tables.
*/
export interface VirtualDatasetCreatePayload {
database: number;
schema: string;
schema: string | null;
table_name: string;
sql: string;
owners?: number[];
@@ -55,8 +59,8 @@ export interface VirtualDatasetCreatePayload {
export interface DatasetResult {
id: number;
table_name: string;
sql?: string;
schema?: string;
sql?: string | null;
schema?: string | null;
database: {
id: number;
database_name: string;
@@ -79,11 +83,11 @@ export async function apiPostDataset(
}
/**
* POST request to create a virtual (SQL-based) dataset
* Virtual datasets support the Duplicate action in the UI
* POST request to create a virtual dataset with SQL.
* Use expectStatusOneOf() on the response and handle both result.id and id shapes.
* @param page - Playwright page instance (provides authentication context)
* @param requestBody - Virtual dataset config (database, schema, table_name, sql)
* @returns API response from dataset creation
* @param requestBody - Virtual dataset configuration (database, schema, table_name, sql)
* @returns API response from virtual dataset creation
*/
export async function apiPostVirtualDataset(
page: Page,
@@ -96,16 +100,27 @@ export async function apiPostVirtualDataset(
* Creates a simple virtual dataset for testing purposes
* @param page - Playwright page instance
* @param name - Name for the virtual dataset
* @param databaseId - ID of the database to use (defaults to 1 for examples db)
* @param databaseId - ID of the database to use (looks up 'examples' DB if not provided)
* @returns The created dataset ID, or null on failure
*/
export async function createTestVirtualDataset(
page: Page,
name: string,
databaseId = 1,
databaseId?: number,
): Promise<number | null> {
// Look up examples database if no ID provided
let dbId = databaseId;
if (dbId === undefined) {
const examplesDb = await getDatabaseByName(page, 'examples');
if (!examplesDb?.id) {
console.warn('Failed to find examples database');
return null;
}
dbId = examplesDb.id;
}
const response = await apiPostVirtualDataset(page, {
database: databaseId,
database: dbId,
schema: '',
table_name: name,
sql: "SELECT 1 as id, 'test' as name",
@@ -118,7 +133,8 @@ export async function createTestVirtualDataset(
}
const body = await response.json();
return body.id ?? null;
// Handle both response shapes: { id } or { result: { id } }
return body.result?.id ?? body.id ?? null;
}
/**
@@ -186,3 +202,30 @@ export async function apiDeleteDataset(
): Promise<APIResponse> {
return apiDelete(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
}
/**
* Duplicate a dataset via the API
* @param page - Playwright page instance (provides authentication context)
* @param datasetId - ID of the dataset to duplicate
* @param newName - Name for the duplicated dataset
* @returns Object containing the new dataset's ID (use apiGetDataset for full details)
*/
export async function duplicateDataset(
page: Page,
datasetId: number,
newName: string,
): Promise<{ id: number }> {
const response = await apiPost(page, `${ENDPOINTS.DATASET}duplicate`, {
base_model_id: datasetId,
table_name: newName,
});
const body = await response.json();
// Normalize: API may return id at top level or inside result
const resolvedId = body.result?.id ?? body.id;
if (!resolvedId) {
throw new Error(
`Duplicate dataset API returned no id. Response: ${JSON.stringify(body)}`,
);
}
return { id: resolvedId };
}

View File

@@ -0,0 +1,145 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { Page, Response } from '@playwright/test';
/**
* HTTP methods enum for consistency
*/
export const HTTP_METHODS = {
GET: 'GET',
POST: 'POST',
PUT: 'PUT',
DELETE: 'DELETE',
PATCH: 'PATCH',
} as const;
type HttpMethod = (typeof HTTP_METHODS)[keyof typeof HTTP_METHODS];
/**
* Options for waitFor* functions
*/
interface WaitForResponseOptions {
/** Optional timeout in milliseconds */
timeout?: number;
/** Match against URL pathname suffix instead of full URL includes (default: false) */
pathMatch?: boolean;
}
/**
* Normalize a path by removing trailing slashes
*/
function normalizePath(path: string): string {
return path.replace(/\/+$/, '');
}
/**
* Check if a URL matches a pattern
* - String + pathMatch: pathname.endsWith(pattern) with trailing slash normalization
* - String: url.includes(pattern)
* - RegExp: pattern.test(url)
*/
function matchUrl(
url: string,
pattern: string | RegExp,
pathMatch?: boolean,
): boolean {
if (typeof pattern === 'string') {
if (pathMatch) {
const pathname = normalizePath(new URL(url).pathname);
const normalizedPattern = normalizePath(pattern);
return pathname.endsWith(normalizedPattern);
}
return url.includes(pattern);
}
return pattern.test(url);
}
/**
* Generic helper to wait for a response matching URL pattern and HTTP method
*/
function waitForResponse(
page: Page,
urlPattern: string | RegExp,
method: HttpMethod,
options?: WaitForResponseOptions,
): Promise<Response> {
const { pathMatch, ...waitOptions } = options ?? {};
return page.waitForResponse(
response =>
matchUrl(response.url(), urlPattern, pathMatch) &&
response.request().method() === method,
waitOptions,
);
}
/**
* Wait for a GET response matching the URL pattern
*/
export function waitForGet(
page: Page,
urlPattern: string | RegExp,
options?: WaitForResponseOptions,
): Promise<Response> {
return waitForResponse(page, urlPattern, HTTP_METHODS.GET, options);
}
/**
* Wait for a POST response matching the URL pattern
*/
export function waitForPost(
page: Page,
urlPattern: string | RegExp,
options?: WaitForResponseOptions,
): Promise<Response> {
return waitForResponse(page, urlPattern, HTTP_METHODS.POST, options);
}
/**
* Wait for a PUT response matching the URL pattern
*/
export function waitForPut(
page: Page,
urlPattern: string | RegExp,
options?: WaitForResponseOptions,
): Promise<Response> {
return waitForResponse(page, urlPattern, HTTP_METHODS.PUT, options);
}
/**
* Wait for a DELETE response matching the URL pattern
*/
export function waitForDelete(
page: Page,
urlPattern: string | RegExp,
options?: WaitForResponseOptions,
): Promise<Response> {
return waitForResponse(page, urlPattern, HTTP_METHODS.DELETE, options);
}
/**
* Wait for a PATCH response matching the URL pattern
*/
export function waitForPatch(
page: Page,
urlPattern: string | RegExp,
options?: WaitForResponseOptions,
): Promise<Response> {
return waitForResponse(page, urlPattern, HTTP_METHODS.PATCH, options);
}

View File

@@ -0,0 +1,21 @@
/**
* 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.
*/
// Base fixture with test asset cleanup
export { test as testWithAssets, expect, type TestAssets } from './testAssets';

View File

@@ -0,0 +1,68 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { test as base } from '@playwright/test';
import { apiDeleteDataset } from '../api/dataset';
import { apiDeleteDatabase } from '../api/database';
/**
* Test asset tracker for automatic cleanup after each test.
* Inspired by Cypress's cleanDashboards/cleanCharts pattern.
*/
export interface TestAssets {
trackDataset(id: number): void;
trackDatabase(id: number): void;
}
export const test = base.extend<{ testAssets: TestAssets }>({
testAssets: async ({ page }, use) => {
// Use Set to de-dupe IDs (same resource may be tracked multiple times)
const datasetIds = new Set<number>();
const databaseIds = new Set<number>();
await use({
trackDataset: id => datasetIds.add(id),
trackDatabase: id => databaseIds.add(id),
});
// Cleanup: Delete datasets FIRST (they reference databases)
// Then delete databases. Use failOnStatusCode: false for tolerance.
await Promise.all(
[...datasetIds].map(id =>
apiDeleteDataset(page, id, { failOnStatusCode: false }).catch(error => {
console.warn(`[testAssets] Failed to cleanup dataset ${id}:`, error);
}),
),
);
await Promise.all(
[...databaseIds].map(id =>
apiDeleteDatabase(page, id, { failOnStatusCode: false }).catch(
error => {
console.warn(
`[testAssets] Failed to cleanup database ${id}:`,
error,
);
},
),
),
);
},
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,138 @@
/**
* 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 { expect, Locator, Page } from '@playwright/test';
import { Button, Select } from '../components/core';
/**
* Chart Creation Page object for the "Create a new chart" wizard.
* This page appears after creating a dataset via the wizard.
*/
export class ChartCreationPage {
readonly page: Page;
private static readonly SELECTORS = {
VIZ_GALLERY: '.viz-gallery',
VIZ_TYPE_ITEM: '[data-test="viz-type-gallery__item"]',
} as const;
constructor(page: Page) {
this.page = page;
}
/**
* Gets the dataset selector container (includes the displayed selection value)
*/
getDatasetSelectContainer(): Locator {
return this.page.getByLabel('Dataset', { exact: false }).first();
}
/**
* Gets the dataset selector for interactions
*/
getDatasetSelect(): Select {
return new Select(
this.page,
this.page.getByRole('combobox', { name: /dataset/i }),
);
}
/**
* Gets the visualization gallery container
*/
getVizGallery(): Locator {
return this.page.locator(ChartCreationPage.SELECTORS.VIZ_GALLERY);
}
/**
* Gets the "Create new chart" button
*/
getCreateChartButton(): Button {
return new Button(
this.page,
this.page.getByRole('button', { name: /create new chart/i }),
);
}
/**
* Navigate to the chart creation page
*/
async goto(): Promise<void> {
await this.page.goto('chart/add');
}
/**
* Wait for the page to load (dataset selector visible)
*/
async waitForPageLoad(): Promise<void> {
await expect(this.getDatasetSelect().element).toBeVisible({
timeout: 10000,
});
}
/**
* Select a dataset from the dropdown
* @param datasetName - The name of the dataset to select
*/
async selectDataset(datasetName: string): Promise<void> {
await this.getDatasetSelect().selectOption(datasetName);
}
/**
* Select a visualization type from the gallery
* @param vizType - The visualization type to select (e.g., 'Table', 'Bar Chart')
*/
async selectVizType(vizType: string): Promise<void> {
const vizGallery = this.getVizGallery();
await expect(vizGallery).toBeVisible();
// Button names in the gallery are duplicated (e.g., "Table Table", "Bar Chart Bar Chart")
// because they include both the image alt text and the label text.
// Use exact match with the duplicated pattern to avoid matching similar names.
const vizTypeItem = vizGallery.getByRole('button', {
name: `${vizType} ${vizType}`,
exact: true,
});
await vizTypeItem.click();
}
/**
* Click the "Create new chart" button to navigate to Explore
*/
async clickCreateNewChart(): Promise<void> {
await this.getCreateChartButton().click();
}
/**
* Verify the dataset is pre-selected (shown in the selector)
* @param datasetName - The expected dataset name
*/
async expectDatasetSelected(datasetName: string): Promise<void> {
// For Ant Design selects, the selected value is displayed in a sibling element,
// not in the combobox input. Check the container for the displayed text.
await expect(this.getDatasetSelectContainer()).toContainText(datasetName);
}
/**
* Check if the "Create new chart" button is enabled
*/
async isCreateButtonEnabled(): Promise<boolean> {
return this.getCreateChartButton().isEnabled();
}
}

View File

@@ -0,0 +1,138 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Page } from '@playwright/test';
import { Button, Select } from '../components/core';
/**
* Create Dataset Page object for the dataset creation wizard.
*/
export class CreateDatasetPage {
readonly page: Page;
/**
* Data-test selectors for the create dataset form elements.
* Using data-test attributes avoids strict mode violations with multiple selects.
*/
private static readonly SELECTORS = {
DATABASE: '[data-test="select-database"]',
SCHEMA: '[data-test="Select schema or type to search schemas"]',
TABLE: '[data-test="Select table or type to search tables"]',
} as const;
constructor(page: Page) {
this.page = page;
}
/**
* Gets the database selector using data-test attribute
*/
getDatabaseSelect(): Select {
return new Select(this.page, CreateDatasetPage.SELECTORS.DATABASE);
}
/**
* Gets the schema selector using data-test attribute
*/
getSchemaSelect(): Select {
return new Select(this.page, CreateDatasetPage.SELECTORS.SCHEMA);
}
/**
* Gets the table selector using data-test attribute
*/
getTableSelect(): Select {
return new Select(this.page, CreateDatasetPage.SELECTORS.TABLE);
}
/**
* Gets the create and explore button
*/
getCreateAndExploreButton(): Button {
return new Button(
this.page,
this.page.getByRole('button', { name: /Create and explore dataset/i }),
);
}
/**
* Navigate to the create dataset page
*/
async goto(): Promise<void> {
await this.page.goto('dataset/add/');
}
/**
* Select a database from the dropdown
* @param databaseName - The name of the database to select
*/
async selectDatabase(databaseName: string): Promise<void> {
await this.getDatabaseSelect().selectOption(databaseName);
}
/**
* Select a schema from the dropdown
* @param schemaName - The name of the schema to select
*/
async selectSchema(schemaName: string): Promise<void> {
await this.getSchemaSelect().selectOption(schemaName);
}
/**
* Select a table from the dropdown
* @param tableName - The name of the table to select
*/
async selectTable(tableName: string): Promise<void> {
await this.getTableSelect().selectOption(tableName);
}
/**
* Click the "Create dataset" button (without exploring)
* Uses the dropdown menu to select "Create dataset" option
*/
async clickCreateDataset(): Promise<void> {
// Find the "Create and explore dataset" button, then its sibling dropdown trigger
// This avoids ambiguity if other "down" buttons exist on the page
const mainButton = this.page.getByRole('button', {
name: /Create and explore dataset/i,
});
// The dropdown trigger is in the same button group, find it relative to main button
const dropdownTrigger = mainButton
.locator('xpath=following-sibling::button')
.first();
await dropdownTrigger.click();
// Click "Create dataset" option from the dropdown menu
await this.page.getByText('Create dataset', { exact: true }).click();
}
/**
* Click the "Create and explore dataset" button
*/
async clickCreateAndExploreDataset(): Promise<void> {
await this.getCreateAndExploreButton().click();
}
/**
* Wait for the page to load
*/
async waitForPageLoad(): Promise<void> {
await this.getDatabaseSelect().element.waitFor({ state: 'visible' });
}
}

View File

@@ -18,7 +18,8 @@
*/
import { Page, Locator } from '@playwright/test';
import { Table } from '../components/core';
import { Button, Table } from '../components/core';
import { BulkSelect } from '../components/ListView';
import { URL } from '../utils/urls';
/**
@@ -27,17 +28,26 @@ import { URL } from '../utils/urls';
export class DatasetListPage {
private readonly page: Page;
private readonly table: Table;
readonly bulkSelect: BulkSelect;
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;
/**
* Action button names for getByRole('button', { name })
*/
private static readonly ACTION_BUTTONS = {
DELETE: 'delete',
EDIT: 'edit',
EXPORT: 'upload', // Export button uses upload icon
DUPLICATE: 'copy',
} as const;
constructor(page: Page) {
this.page = page;
this.table = new Table(page);
this.bulkSelect = new BulkSelect(page, this.table);
}
/**
@@ -85,10 +95,21 @@ export class DatasetListPage {
* @param datasetName - The name of the dataset to delete
*/
async clickDeleteAction(datasetName: string): Promise<void> {
await this.table.clickRowAction(
datasetName,
DatasetListPage.SELECTORS.DELETE_ACTION,
);
const row = this.table.getRow(datasetName);
await row
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DELETE })
.click();
}
/**
* Clicks the edit action button for a dataset
* @param datasetName - The name of the dataset to edit
*/
async clickEditAction(datasetName: string): Promise<void> {
const row = this.table.getRow(datasetName);
await row
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EDIT })
.click();
}
/**
@@ -96,10 +117,10 @@ export class DatasetListPage {
* @param datasetName - The name of the dataset to export
*/
async clickExportAction(datasetName: string): Promise<void> {
await this.table.clickRowAction(
datasetName,
DatasetListPage.SELECTORS.EXPORT_ACTION,
);
const row = this.table.getRow(datasetName);
await row
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EXPORT })
.click();
}
/**
@@ -107,9 +128,57 @@ export class DatasetListPage {
* @param datasetName - The name of the dataset to duplicate
*/
async clickDuplicateAction(datasetName: string): Promise<void> {
await this.table.clickRowAction(
datasetName,
DatasetListPage.SELECTORS.DUPLICATE_ACTION,
const row = this.table.getRow(datasetName);
await row
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DUPLICATE })
.click();
}
/**
* Clicks the "Bulk select" button to enable bulk selection mode
*/
async clickBulkSelectButton(): Promise<void> {
await this.bulkSelect.enable();
}
/**
* Selects a dataset's checkbox in bulk select mode
* @param datasetName - The name of the dataset to select
*/
async selectDatasetCheckbox(datasetName: string): Promise<void> {
await this.bulkSelect.selectRow(datasetName);
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* @param actionName - The name of the bulk action to click
*/
async clickBulkAction(actionName: string): Promise<void> {
await this.bulkSelect.clickAction(actionName);
}
/**
* Gets the "+ Dataset" button for creating new datasets.
* Uses specific selector to avoid matching the "Datasets" nav link.
*/
getAddDatasetButton(): Button {
return new Button(
this.page,
this.page.getByRole('button', { name: /^\+ Dataset$|^plus Dataset$/ }),
);
}
/**
* Clicks the "+ Dataset" button to navigate to create dataset page
*/
async clickAddDataset(): Promise<void> {
await this.getAddDatasetButton().click();
}
/**
* Clicks the import button to open the import modal
*/
async clickImportButton(): Promise<void> {
await this.page.getByTestId('import-button').click();
}
}

View File

@@ -0,0 +1,219 @@
/**
* 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 '../../../helpers/fixtures/testAssets';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import type { Page, TestInfo } from '@playwright/test';
import { ExplorePage } from '../../../pages/ExplorePage';
import { CreateDatasetPage } from '../../../pages/CreateDatasetPage';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ChartCreationPage } from '../../../pages/ChartCreationPage';
import { ENDPOINTS } from '../../../helpers/api/dataset';
import { waitForPost } from '../../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../../helpers/api/assertions';
import { apiPostDatabase } from '../../../helpers/api/database';
interface GsheetsSetupResult {
sheetName: string;
dbName: string;
createDatasetPage: CreateDatasetPage;
}
/**
* Sets up gsheets database and navigates to create dataset page.
* Skips test if gsheets connector unavailable (test.skip() throws, so no return).
* @param testInfo - Test info for parallelIndex to avoid name collisions in parallel runs
* @returns Setup result with names and page object
*/
async function setupGsheetsDataset(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
): Promise<GsheetsSetupResult> {
// Public Google Sheet for testing (published to web, no auth required).
// This is a Netflix dataset that is publicly accessible via the Google Visualization API.
// NOTE: This sheet is hosted on an external Google account and is not created by the test itself.
// If this sheet is deleted, its ID changes, or its sharing settings are restricted,
// these tests will start failing when they attempt to create a database pointing at it.
// In that case, create or select a new publicly readable test sheet, update `sheetUrl`
// to use its URL, and update this comment to describe who owns/maintains that sheet
// and the expected access controls (e.g., "anyone with the link can view").
const sheetUrl =
'https://docs.google.com/spreadsheets/d/19XNqckHGKGGPh83JGFdFGP4Bw9gdXeujq5EoIGwttdM/edit#gid=347941303';
// Include parallelIndex to avoid collisions when tests run in parallel
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
const sheetName = `test_netflix_${uniqueSuffix}`;
const dbName = `test_gsheets_db_${uniqueSuffix}`;
// Create a Google Sheets database via API
// The catalog must be in `extra` as JSON with engine_params.catalog format
const catalogDict = { [sheetName]: sheetUrl };
const createDbRes = await apiPostDatabase(page, {
database_name: dbName,
engine: 'gsheets',
sqlalchemy_uri: 'gsheets://',
configuration_method: 'dynamic_form',
expose_in_sqllab: true,
extra: JSON.stringify({
engine_params: {
catalog: catalogDict,
},
}),
});
// Check if gsheets connector is available
if (!createDbRes.ok()) {
const errorBody = await createDbRes.json();
const errorText = JSON.stringify(errorBody);
// Skip test if gsheets connector not installed
if (
errorText.includes('gsheets') ||
errorText.includes('No such DB engine')
) {
await test.info().attach('skip-reason', {
body: `Google Sheets connector unavailable: ${errorText}`,
contentType: 'text/plain',
});
test.skip(); // throws, no return needed
}
throw new Error(`Failed to create gsheets database: ${errorText}`);
}
const createDbBody = await createDbRes.json();
const dbId = createDbBody.result?.id ?? createDbBody.id;
if (!dbId) {
throw new Error('Database creation did not return an ID');
}
testAssets.trackDatabase(dbId);
// Navigate to create dataset page
const createDatasetPage = new CreateDatasetPage(page);
await createDatasetPage.goto();
await createDatasetPage.waitForPageLoad();
// Select the Google Sheets database
await createDatasetPage.selectDatabase(dbName);
// Try to select the sheet - if not found due to timeout, skip
try {
await createDatasetPage.selectTable(sheetName);
} catch (error) {
// Only skip on TimeoutError (sheet not loaded); re-throw everything else
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
await test.info().attach('skip-reason', {
body: `Table "${sheetName}" not found in dropdown after timeout.`,
contentType: 'text/plain',
});
test.skip(); // throws, no return needed
}
return { sheetName, dbName, createDatasetPage };
}
test('should create a dataset via wizard', async ({ page, testAssets }) => {
const { sheetName, createDatasetPage } = await setupGsheetsDataset(
page,
testAssets,
test.info(),
);
// Set up response intercept to capture new dataset ID
const createResponsePromise = waitForPost(page, ENDPOINTS.DATASET, {
pathMatch: true,
});
// Click "Create and explore dataset" button
await createDatasetPage.clickCreateAndExploreDataset();
// Wait for dataset creation and capture ID for cleanup
const createResponse = expectStatusOneOf(
await createResponsePromise,
[200, 201],
);
const createBody = await createResponse.json();
const newDatasetId = createBody.result?.id ?? createBody.id;
if (newDatasetId) {
testAssets.trackDataset(newDatasetId);
}
// Verify we navigated to Chart Creation page with dataset pre-selected
await page.waitForURL(/.*\/chart\/add.*/);
const chartCreationPage = new ChartCreationPage(page);
await chartCreationPage.waitForPageLoad();
// Verify the dataset is pre-selected
await chartCreationPage.expectDatasetSelected(sheetName);
// Select a visualization type and create chart
await chartCreationPage.selectVizType('Table');
// Click "Create new chart" to go to Explore
await chartCreationPage.clickCreateNewChart();
// Verify we navigated to Explore page
await page.waitForURL(/.*\/explore\/.*/);
const explorePage = new ExplorePage(page);
await explorePage.waitForPageLoad();
// Verify the dataset name is shown in Explore
const loadedDatasetName = await explorePage.getDatasetName();
expect(loadedDatasetName).toContain(sheetName);
});
test('should create a dataset without exploring', async ({
page,
testAssets,
}) => {
const { sheetName, createDatasetPage } = await setupGsheetsDataset(
page,
testAssets,
test.info(),
);
// Set up response intercept to capture dataset ID
const createResponsePromise = waitForPost(page, ENDPOINTS.DATASET, {
pathMatch: true,
});
// Click "Create dataset" (not explore)
await createDatasetPage.clickCreateDataset();
// Capture dataset ID from response for cleanup
const createResponse = expectStatusOneOf(
await createResponsePromise,
[200, 201],
);
const createBody = await createResponse.json();
const datasetId = createBody.result?.id ?? createBody.id;
if (datasetId) {
testAssets.trackDataset(datasetId);
}
// Verify redirect to dataset list (not chart creation)
// Note: "Create dataset" action does not show a toast
await page.waitForURL(/.*tablemodelview\/list.*/);
// Wait for table load, verify row visible
const datasetListPage = new DatasetListPage(page);
await datasetListPage.waitForTableLoad();
await expect(datasetListPage.getDatasetRow(sheetName)).toBeVisible();
});

View File

@@ -17,76 +17,91 @@
* under the License.
*/
import { test, expect } from '@playwright/test';
import {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import type { Response } from '@playwright/test';
import path from 'path';
import * as unzipper from 'unzipper';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ExplorePage } from '../../../pages/ExplorePage';
import { ConfirmDialog } from '../../../components/modals/ConfirmDialog';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal';
import { DuplicateDatasetModal } from '../../../components/modals/DuplicateDatasetModal';
import { EditDatasetModal } from '../../../components/modals/EditDatasetModal';
import { Toast } from '../../../components/core/Toast';
import {
apiDeleteDataset,
apiGetDataset,
apiPostVirtualDataset,
getDatasetByName,
createTestVirtualDataset,
ENDPOINTS,
} from '../../../helpers/api/dataset';
import { createTestDataset } from './dataset-test-helpers';
import {
waitForGet,
waitForPost,
waitForPut,
} from '../../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../../helpers/api/assertions';
import { TIMEOUT } from '../../../utils/constants';
/**
* Test data constants
* PHYSICAL_DATASET: A physical dataset from examples (for navigation tests)
* Tests that need virtual datasets (duplicate/delete) create their own hermetic data
* Extend testWithAssets with datasetListPage navigation (beforeEach equivalent).
*/
const TEST_DATASETS = {
/** Physical dataset for basic navigation tests */
PHYSICAL_DATASET: 'birth_names',
} 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();
const test = testWithAssets.extend<{ datasetListPage: DatasetListPage }>({
datasetListPage: async ({ page }, use) => {
const datasetListPage = new DatasetListPage(page);
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await use(datasetListPage);
},
});
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),
);
}),
/**
* Helper to validate an export zip response.
* Verifies headers, parses zip contents, and validates expected structure.
*/
async function expectValidExportZip(
response: Response,
options: { minDatasetCount?: number; checkContentDisposition?: boolean } = {},
): Promise<void> {
const { minDatasetCount = 1, checkContentDisposition = false } = options;
// Verify headers
expect(response.headers()['content-type']).toContain('application/zip');
if (checkContentDisposition) {
expect(response.headers()['content-disposition']).toMatch(
/filename=.*dataset_export.*\.zip/,
);
}
await Promise.all(promises);
});
// Parse and validate zip contents
const body = await response.body();
expect(body.length).toBeGreaterThan(0);
const entries: string[] = [];
const directory = await unzipper.Open.buffer(body);
directory.files.forEach(file => entries.push(file.path));
// Validate structure
const datasetYamlFiles = entries.filter(
entry => entry.includes('datasets/') && entry.endsWith('.yaml'),
);
expect(datasetYamlFiles.length).toBeGreaterThanOrEqual(minDatasetCount);
expect(entries.some(entry => entry.endsWith('metadata.yaml'))).toBe(true);
}
test('should navigate to Explore when dataset name is clicked', async ({
page,
datasetListPage,
}) => {
// Use existing physical dataset (loaded in CI via --load-examples)
const datasetName = TEST_DATASETS.PHYSICAL_DATASET;
const explorePage = new ExplorePage(page);
// Use existing example dataset (hermetic - loaded in CI via --load-examples)
const datasetName = 'members_channels_2';
const dataset = await getDatasetByName(page, datasetName);
expect(dataset).not.toBeNull();
@@ -108,16 +123,20 @@ test('should navigate to Explore when dataset name is clicked', async ({
await expect(explorePage.getVizSwitcher()).toContainText('Table');
});
test('should delete a dataset with confirmation', async ({ page }) => {
// Create a virtual dataset for this test (hermetic - no dependency on examples)
const datasetName = `test_delete_${Date.now()}`;
const datasetId = await createTestVirtualDataset(page, datasetName);
expect(datasetId).not.toBeNull();
test('should delete a dataset with confirmation', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create throwaway dataset for deletion
const { id: datasetId, name: datasetName } = await createTestDataset(
page,
testAssets,
test.info(),
{ prefix: 'test_delete' },
);
// Track for cleanup in case test fails partway through
testResources = { datasetIds: [datasetId!] };
// Refresh page to see new dataset
// Refresh to see the new dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
@@ -148,31 +167,44 @@ test('should delete a dataset with confirmation', async ({ page }) => {
// Verify dataset is removed from list
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
// Verify via API that dataset no longer exists (404)
await expect
.poll(
async () => {
const response = await apiGetDataset(page, datasetId, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Dataset ${datasetId} should return 404` },
)
.toBe(404);
});
test('should duplicate a dataset with new name', async ({ page }) => {
// Create a virtual dataset for this test (hermetic - no dependency on examples)
const originalName = `test_original_${Date.now()}`;
const originalId = await createTestVirtualDataset(page, originalName);
expect(originalId).not.toBeNull();
test('should duplicate a dataset with new name', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create a virtual dataset first (duplicate UI only works for virtual datasets)
const { id: originalId, name: originalName } = await createTestDataset(
page,
testAssets,
test.info(),
{ prefix: 'test_duplicate_source' },
);
const duplicateName = `duplicate_${Date.now()}_${test.info().parallelIndex}`;
// Track original for cleanup
testResources = { datasetIds: [originalId!] };
const duplicateName = `duplicate_${originalName}`;
// Refresh page to see new dataset
// Navigate to list and verify original dataset is visible
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// 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,
const duplicateResponsePromise = waitForPost(
page,
ENDPOINTS.DATASET_DUPLICATE,
);
// Click duplicate action button
@@ -188,13 +220,17 @@ test('should duplicate a dataset with new name', async ({ page }) => {
// Click the Duplicate button
await duplicateModal.clickDuplicate();
// Get the duplicate dataset ID from response
const duplicateResponse = await duplicateResponsePromise;
// Get the duplicate dataset ID from response (handle both response shapes)
const duplicateResponse = expectStatusOneOf(
await duplicateResponsePromise,
[200, 201],
);
const duplicateData = await duplicateResponse.json();
const duplicateId = duplicateData.id;
const duplicateId = duplicateData.result?.id ?? duplicateData.id;
expect(duplicateId, 'Duplicate API should return dataset id').toBeTruthy();
// Track both original and duplicate for cleanup
testResources = { datasetIds: [originalId!, duplicateId] };
// Track duplicate for cleanup (original is already tracked by createTestDataset)
testAssets.trackDataset(duplicateId);
// Modal should close
await duplicateModal.waitForHidden();
@@ -210,17 +246,437 @@ test('should duplicate a dataset with new name', async ({ page }) => {
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
// API Verification: Compare original and duplicate datasets
const originalResponseData = await apiGetDataset(page, originalId!);
const originalDataFull = await originalResponseData.json();
const duplicateResponseData = await apiGetDataset(page, duplicateId);
const duplicateDataFull = await duplicateResponseData.json();
// API Verification: Fetch both datasets via detail API for consistent comparison
// (list API may return undefined for fields that detail API returns as null)
const [originalDetailRes, duplicateDetailRes] = await Promise.all([
apiGetDataset(page, originalId),
apiGetDataset(page, duplicateId),
]);
const originalDetail = (await originalDetailRes.json()).result;
const duplicateDetail = (await duplicateDetailRes.json()).result;
// Verify key properties were copied correctly
expect(duplicateDataFull.result.sql).toBe(originalDataFull.result.sql);
expect(duplicateDataFull.result.database.id).toBe(
originalDataFull.result.database.id,
);
expect(duplicateDetail.sql).toBe(originalDetail.sql);
expect(duplicateDetail.database.id).toBe(originalDetail.database.id);
expect(duplicateDetail.schema).toBe(originalDetail.schema);
// Name should be different (the duplicate name)
expect(duplicateDataFull.result.table_name).toBe(duplicateName);
expect(duplicateDetail.table_name).toBe(duplicateName);
});
test('should export a dataset as a zip file', async ({
page,
datasetListPage,
}) => {
// Use existing example dataset
const datasetName = 'members_channels_2';
const dataset = await getDatasetByName(page, datasetName);
expect(dataset).not.toBeNull();
// Verify dataset is visible in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// Set up API response intercept for export endpoint
// Note: We intercept the API response instead of relying on download events because
// Superset uses blob downloads (createObjectURL) which don't trigger Playwright's
// download event consistently, especially in app-prefix configurations.
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT);
// Click export action button
await datasetListPage.clickExportAction(datasetName);
// Wait for export API response and validate zip contents
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
await expectValidExportZip(exportResponse, { checkContentDisposition: true });
});
test('should export multiple datasets via bulk select action', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create 2 throwaway datasets for bulk export
const [dataset1, dataset2] = await Promise.all([
createTestDataset(page, testAssets, test.info(), {
prefix: 'bulk_export_1',
}),
createTestDataset(page, testAssets, test.info(), {
prefix: 'bulk_export_2',
}),
]);
// Refresh to see new datasets
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets are visible in list
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
// Enable bulk select mode
await datasetListPage.clickBulkSelectButton();
// Select both datasets
await datasetListPage.selectDatasetCheckbox(dataset1.name);
await datasetListPage.selectDatasetCheckbox(dataset2.name);
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT);
// Click bulk export action
await datasetListPage.clickBulkAction('Export');
// Wait for export API response and validate zip contains multiple datasets
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
await expectValidExportZip(exportResponse, { minDatasetCount: 2 });
});
test('should edit dataset name via modal', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create throwaway dataset for editing
const { id: datasetId, name: datasetName } = await createTestDataset(
page,
testAssets,
test.info(),
{ prefix: 'test_edit' },
);
// Refresh to see new dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset is visible in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// Click edit action to open modal
await datasetListPage.clickEditAction(datasetName);
// Wait for edit modal to be ready
const editModal = new EditDatasetModal(page);
await editModal.waitForReady();
// Enable edit mode by clicking the lock icon
await editModal.enableEditMode();
// Edit the dataset name
const newName = `test_renamed_${Date.now()}`;
await editModal.fillName(newName);
// Set up response intercept for save
const saveResponsePromise = waitForPut(
page,
`${ENDPOINTS.DATASET}${datasetId}`,
);
// Click Save button
await editModal.clickSave();
// Handle the "Confirm save" dialog that may appear for datasets with sync columns enabled
const confirmDialog = new ConfirmDialog(page);
await confirmDialog.clickOk({ timeout: TIMEOUT.CONFIRM_DIALOG });
// Wait for save to complete and verify success
expectStatusOneOf(await saveResponsePromise, [200, 201]);
// Modal should close
await editModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
// Verify via API that name was saved
const updatedDatasetRes = await apiGetDataset(page, datasetId);
const updatedDataset = (await updatedDatasetRes.json()).result;
expect(updatedDataset.table_name).toBe(newName);
});
test('should bulk delete multiple datasets', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create 2 throwaway datasets for bulk delete
const [dataset1, dataset2] = await Promise.all([
createTestDataset(page, testAssets, test.info(), {
prefix: 'bulk_delete_1',
}),
createTestDataset(page, testAssets, test.info(), {
prefix: 'bulk_delete_2',
}),
]);
// Refresh to see new datasets
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets are visible in list
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
// Enable bulk select mode
await datasetListPage.clickBulkSelectButton();
// Select both datasets
await datasetListPage.selectDatasetCheckbox(dataset1.name);
await datasetListPage.selectDatasetCheckbox(dataset2.name);
// Click bulk delete action
await datasetListPage.clickBulkAction('Delete');
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both datasets are removed from list
await expect(datasetListPage.getDatasetRow(dataset1.name)).not.toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).not.toBeVisible();
// Verify via API that datasets no longer exist (404)
// Use polling with explicit timeout since deletes may be async
await expect
.poll(
async () => {
const response = await apiGetDataset(page, dataset1.id, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Dataset ${dataset1.id} should return 404` },
)
.toBe(404);
await expect
.poll(
async () => {
const response = await apiGetDataset(page, dataset2.id, {
failOnStatusCode: false,
});
return response.status();
},
{ timeout: 10000, message: `Dataset ${dataset2.id} should return 404` },
)
.toBe(404);
});
// Import test uses a fixed dataset name from the zip fixture.
// Uses test.describe only because Playwright's serial mode API requires it -
// this prevents race conditions when parallel workers import the same fixture.
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
test.describe('import dataset', () => {
test.describe.configure({ mode: 'serial' });
test('should import a dataset from a zip file', async ({
page,
datasetListPage,
testAssets,
}) => {
// Dataset name from fixture (test_netflix_1768502050965)
// Note: Fixture contains a Google Sheets dataset - test will skip if gsheets connector unavailable
const importedDatasetName = 'test_netflix_1768502050965';
const fixturePath = path.resolve(
__dirname,
'../../../fixtures/dataset_export.zip',
);
// Cleanup: Delete any existing dataset with the same name from previous runs
const existingDataset = await getDatasetByName(page, importedDatasetName);
if (existingDataset) {
await apiDeleteDataset(page, existingDataset.id, {
failOnStatusCode: false,
});
}
// Click the import button
await datasetListPage.clickImportButton();
// Wait for import modal to be ready
const importModal = new ImportDatasetModal(page);
await importModal.waitForReady();
// Upload the fixture zip file
await importModal.uploadFile(fixturePath);
// Set up response intercept to catch the import POST
// Use pathMatch to avoid false matches if URL lacks trailing slash
let importResponsePromise = waitForPost(page, ENDPOINTS.DATASET_IMPORT, {
pathMatch: true,
});
// Click Import button
await importModal.clickImport();
// Wait for first import response
let importResponse = await importResponsePromise;
// Handle overwrite confirmation if dataset already exists
// First response may be 409/422 indicating overwrite is required - this is expected
const overwriteInput = importModal.getOverwriteInput();
await overwriteInput
.waitFor({ state: 'visible', timeout: 3000 })
.catch(error => {
// Only ignore TimeoutError (input not visible); re-throw other errors
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
});
if (await overwriteInput.isVisible()) {
// Set up new intercept for the actual import after overwrite confirmation
importResponsePromise = waitForPost(page, ENDPOINTS.DATASET_IMPORT, {
pathMatch: true,
});
await importModal.fillOverwriteConfirmation();
await importModal.clickImport();
// Wait for the second (final) import response
importResponse = await importResponsePromise;
}
// Check final import response for gsheets connector errors
if (!importResponse.ok()) {
const errorBody = await importResponse.json().catch(() => ({}));
const errorText = JSON.stringify(errorBody);
// Skip test if gsheets connector not installed
if (
errorText.includes('gsheets') ||
errorText.includes('No such DB engine') ||
errorText.includes('Could not load database driver')
) {
await test.info().attach('skip-reason', {
body: `Import failed due to missing gsheets connector: ${errorText}`,
contentType: 'text/plain',
});
test.skip();
return;
}
// Re-throw other errors
throw new Error(`Import failed: ${errorText}`);
}
// Modal should close on success
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
// Refresh the page to see the imported dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset appears in list
await expect(
datasetListPage.getDatasetRow(importedDatasetName),
).toBeVisible();
// Get dataset ID for cleanup
const importedDataset = await getDatasetByName(page, importedDatasetName);
expect(importedDataset).not.toBeNull();
testAssets.trackDataset(importedDataset!.id);
});
});
test('should edit column date format via modal', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create virtual dataset with a date column for testing
// Using SQL to create a dataset with 'ds' column avoids duplication issues
const datasetName = `test_date_format_${Date.now()}_${test.info().parallelIndex}`;
const baseDataset = await getDatasetByName(page, 'members_channels_2');
expect(baseDataset, 'members_channels_2 dataset must exist').not.toBeNull();
const createResponse = await apiPostVirtualDataset(page, {
database: baseDataset!.database.id,
schema: baseDataset!.schema ?? null,
table_name: datasetName,
sql: "SELECT CAST('2024-01-01' AS DATE) as ds, 'test' as name",
});
expectStatusOneOf(createResponse, [200, 201]);
const createBody = await createResponse.json();
const datasetId = createBody.result?.id ?? createBody.id;
expect(datasetId, 'Virtual dataset creation should return id').toBeTruthy();
testAssets.trackDataset(datasetId);
// Navigate to dataset list, click edit action
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await datasetListPage.clickEditAction(datasetName);
// Enable edit mode, navigate to Columns tab
const editModal = new EditDatasetModal(page);
await editModal.waitForReady();
await editModal.enableEditMode();
await editModal.clickColumnsTab();
// Expand 'ds' column row and fill date format (scoped to row)
const dateFormat = '%Y-%m-%d';
await editModal.fillColumnDateFormat('ds', dateFormat);
// Save and handle confirmation dialog conditionally
await editModal.clickSave();
await new ConfirmDialog(page).clickOk({ timeout: TIMEOUT.CONFIRM_DIALOG });
await editModal.waitForHidden();
// Verify via API
const updatedRes = await apiGetDataset(page, datasetId);
const columns = (await updatedRes.json()).result.columns;
const dsColumn = columns.find(
(c: { column_name: string }) => c.column_name === 'ds',
);
expect(dsColumn, 'ds column should exist in dataset').toBeDefined();
expect(dsColumn.python_date_format).toBe(dateFormat);
});
test('should edit dataset description via modal', async ({
page,
datasetListPage,
testAssets,
}) => {
// Create throwaway dataset for editing description
const { id: datasetId, name: datasetName } = await createTestDataset(
page,
testAssets,
test.info(),
{ prefix: 'test_description' },
);
// Navigate to dataset list, click edit action
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await datasetListPage.clickEditAction(datasetName);
// Enable edit mode, navigate to Settings tab
const editModal = new EditDatasetModal(page);
await editModal.waitForReady();
await editModal.enableEditMode();
await editModal.clickSettingsTab();
// Fill description field
const description = `Test description ${Date.now()}`;
await editModal.fillDescription(description);
// Save and handle confirmation dialog conditionally
await editModal.clickSave();
await new ConfirmDialog(page).clickOk({ timeout: TIMEOUT.CONFIRM_DIALOG });
await editModal.waitForHidden();
// Verify via API
const updatedRes = await apiGetDataset(page, datasetId);
const result = (await updatedRes.json()).result;
expect(result.description).toBe(description);
});

View File

@@ -0,0 +1,67 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { createTestVirtualDataset } from '../../../helpers/api/dataset';
interface TestDatasetResult {
id: number;
name: string;
}
interface CreateTestDatasetOptions {
/** Prefix for generated name (default: 'test') */
prefix?: string;
}
/**
* Creates a test virtual dataset.
* Uses createTestVirtualDataset() to create a simple virtual dataset for testing.
*
* Note: The dataset duplicate API only works with virtual datasets. This helper
* creates virtual datasets directly to avoid that limitation.
*
* @example
* // Basic usage
* const { id, name } = await createTestDataset(page, testAssets, test.info());
*
* @example
* // Custom prefix
* const { id, name } = await createTestDataset(page, testAssets, test.info(), {
* prefix: 'test_delete',
* });
*/
export async function createTestDataset(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
options?: CreateTestDatasetOptions,
): Promise<TestDatasetResult> {
const prefix = options?.prefix ?? 'test';
const name = `${prefix}_${Date.now()}_${testInfo.parallelIndex}`;
const id = await createTestVirtualDataset(page, name);
if (!id) {
throw new Error(`Failed to create test dataset: ${name}`);
}
testAssets.trackDataset(id);
return { id, name };
}

View File

@@ -48,4 +48,14 @@ export const TIMEOUT = {
* API response timeout for operations like export/download
*/
API_RESPONSE: 15000, // 15s for API responses and downloads
/**
* Confirmation dialog wait (e.g., "Confirm save", "Are you sure?")
*/
CONFIRM_DIALOG: 2000, // 2s for confirmation dialogs that may or may not appear
/**
* File import/upload operations (upload + server processing)
*/
FILE_IMPORT: 30000, // 30s for file import operations
} as const;