mirror of
https://github.com/apache/superset.git
synced 2026-04-14 13:44:46 +00:00
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:
26
superset-frontend/package-lock.json
generated
26
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})(),
|
||||
});
|
||||
|
||||
116
superset-frontend/playwright/components/ListView/BulkSelect.ts
Normal file
116
superset-frontend/playwright/components/ListView/BulkSelect.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
21
superset-frontend/playwright/components/ListView/index.ts
Normal file
21
superset-frontend/playwright/components/ListView/index.ts
Normal 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';
|
||||
207
superset-frontend/playwright/components/core/AceEditor.ts
Normal file
207
superset-frontend/playwright/components/core/AceEditor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
95
superset-frontend/playwright/components/core/Checkbox.ts
Normal file
95
superset-frontend/playwright/components/core/Checkbox.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
187
superset-frontend/playwright/components/core/Select.ts
Normal file
187
superset-frontend/playwright/components/core/Select.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
75
superset-frontend/playwright/components/core/Tabs.ts
Normal file
75
superset-frontend/playwright/components/core/Tabs.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
109
superset-frontend/playwright/components/core/Textarea.ts
Normal file
109
superset-frontend/playwright/components/core/Textarea.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -20,3 +20,4 @@
|
||||
// Specific modal implementations
|
||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
|
||||
export { ImportDatasetModal } from './ImportDatasetModal';
|
||||
|
||||
BIN
superset-frontend/playwright/fixtures/dataset_export.zip
Normal file
BIN
superset-frontend/playwright/fixtures/dataset_export.zip
Normal file
Binary file not shown.
61
superset-frontend/playwright/helpers/api/assertions.ts
Normal file
61
superset-frontend/playwright/helpers/api/assertions.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
145
superset-frontend/playwright/helpers/api/intercepts.ts
Normal file
145
superset-frontend/playwright/helpers/api/intercepts.ts
Normal 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);
|
||||
}
|
||||
21
superset-frontend/playwright/helpers/fixtures/index.ts
Normal file
21
superset-frontend/playwright/helpers/fixtures/index.ts
Normal 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';
|
||||
68
superset-frontend/playwright/helpers/fixtures/testAssets.ts
Normal file
68
superset-frontend/playwright/helpers/fixtures/testAssets.ts
Normal 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';
|
||||
138
superset-frontend/playwright/pages/ChartCreationPage.ts
Normal file
138
superset-frontend/playwright/pages/ChartCreationPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
138
superset-frontend/playwright/pages/CreateDatasetPage.ts
Normal file
138
superset-frontend/playwright/pages/CreateDatasetPage.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user