mirror of
https://github.com/apache/superset.git
synced 2026-04-26 03:24:53 +00:00
372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
/**
|
|
* 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, Response } from '@playwright/test';
|
|
import { AceEditor } from '../components/core/AceEditor';
|
|
import { AgGrid } from '../components/core/AgGrid';
|
|
import { Button } from '../components/core/Button';
|
|
import { EditableTabs } from '../components/core/EditableTabs';
|
|
import { Popover } from '../components/core/Popover';
|
|
import { Select } from '../components/core/Select';
|
|
import { waitForPost } from '../helpers/api/intercepts';
|
|
import { URL } from '../utils/urls';
|
|
import { TIMEOUT } from '../utils/constants';
|
|
|
|
/**
|
|
* Page object for SQL Lab.
|
|
*
|
|
* Selectors verified against source code — see plan for line references.
|
|
*/
|
|
export class SqlLabPage {
|
|
private readonly page: Page;
|
|
private readonly editorTabs: EditableTabs;
|
|
|
|
private static readonly SELECTORS = {
|
|
SQL_EDITOR_TABS: '[data-test="sql-editor-tabs"]',
|
|
ADD_TAB_ICON: '[data-test="add-tab-icon"]',
|
|
RUN_QUERY_BUTTON: '[data-test="run-query-action"]',
|
|
SOUTH_PANE: '[data-test="south-pane"]',
|
|
EXPLORE_RESULTS_BUTTON: '[data-test="explore-results-button"]',
|
|
SAVE_BUTTON: 'button[aria-label="Save"]',
|
|
ACE_EDITOR: '.ace_editor',
|
|
LEFT_BAR: '[data-test="sql-editor-left-bar"]',
|
|
DATABASE_SELECTOR: '[data-test="DatabaseSelector"]',
|
|
LIMIT_DROPDOWN: '.limitDropdown',
|
|
SAVE_DATASET_BUTTON: 'button[aria-label="Save dataset"]',
|
|
} as const;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
this.editorTabs = new EditableTabs(
|
|
page,
|
|
page.locator(SqlLabPage.SELECTORS.SQL_EDITOR_TABS),
|
|
);
|
|
}
|
|
|
|
// ── Navigation ──
|
|
|
|
async goto(): Promise<void> {
|
|
await this.page.goto(URL.SQLLAB, { waitUntil: 'domcontentloaded' });
|
|
}
|
|
|
|
async waitForPageLoad(options?: { timeout?: number }): Promise<void> {
|
|
// SQL Lab with dev server can be slow on first load (webpack HMR + React hydration)
|
|
const timeout = options?.timeout ?? TIMEOUT.QUERY_EXECUTION;
|
|
await this.editorTabs.element.waitFor({ state: 'visible', timeout });
|
|
}
|
|
|
|
/**
|
|
* Navigate to SQL Lab and wait until the editor is ready.
|
|
* Convenience method combining goto + waitForPageLoad + ensureEditorReady.
|
|
*/
|
|
async gotoAndReady(): Promise<void> {
|
|
await this.goto();
|
|
await this.waitForPageLoad();
|
|
await this.ensureEditorReady();
|
|
}
|
|
|
|
/**
|
|
* Ensures at least one query editor tab exists. Creates one if SQL Lab
|
|
* is in the empty state ("Add a new tab to create SQL Query").
|
|
* Waits for the ace editor to be ready before returning.
|
|
*
|
|
* Uses a two-stage check to handle three states correctly:
|
|
* 1. Empty state (CI): type="card" with 0 queryEditors, no editor → create tab
|
|
* 2. Loading after reload: real tabs exist, editor hasn't mounted yet → just wait
|
|
* 3. Normal: tabs + editor present → ready immediately
|
|
*
|
|
* Stage 1 checks editor presence (catches empty + loading).
|
|
* Stage 2 checks the Ant Design tabs type to distinguish real tabs
|
|
* (type="editable-card") from the empty state (type="card"). The React
|
|
* source (TabbedSqlEditors) sets type based on queryEditors.length,
|
|
* so this directly reflects whether persisted tabs exist.
|
|
*/
|
|
async ensureEditorReady(): Promise<void> {
|
|
// Page-global check: are there ANY editors in the DOM (any tab)?
|
|
const anyEditor = this.page.locator(SqlLabPage.SELECTORS.ACE_EDITOR);
|
|
let tabSyncPromise: Promise<Response> | null = null;
|
|
|
|
if ((await anyEditor.count()) === 0) {
|
|
// No editor visible. Check if real query editors exist (editable-card)
|
|
// or if this is the empty state (card type, 0 queryEditors).
|
|
// type="editable-card" → queryEditors.length > 0 (even 1 real tab).
|
|
// type="card" → queryEditors.length === 0 (true empty state).
|
|
const isEditableCard = await this.editorTabs.element.evaluate(el =>
|
|
el.classList.contains('ant-tabs-editable-card'),
|
|
);
|
|
if (!isEditableCard) {
|
|
// Register before clicking — EditorAutoSync POSTs the new tab
|
|
// within its 5 s interval, so capture it before any await.
|
|
tabSyncPromise = waitForPost(this.page, /tabstateview\/?$/, {
|
|
timeout: 10_000,
|
|
});
|
|
// True empty state — click add-tab icon (works in card mode)
|
|
await this.editorTabs.element
|
|
.locator(SqlLabPage.SELECTORS.ADD_TAB_ICON)
|
|
.first()
|
|
.click();
|
|
}
|
|
// If editable-card: real tabs exist, editor is still mounting — just wait
|
|
}
|
|
|
|
// Wait for the editor in the ACTIVE panel, not page-global .first().
|
|
// In persisted multi-tab sessions, .first() can resolve to a hidden
|
|
// inactive editor. activePanel scopes to the visible tab panel.
|
|
await this.activePanel
|
|
.locator(SqlLabPage.SELECTORS.ACE_EDITOR)
|
|
.waitFor({ state: 'visible' });
|
|
await this.editor.waitForReady();
|
|
|
|
// If we created the initial tab, wait for its backend sync to complete.
|
|
// This prevents later waitForPost calls from accidentally matching
|
|
// this tab's creation POST instead of a subsequent tab's.
|
|
if (tabSyncPromise) {
|
|
await tabSyncPromise;
|
|
}
|
|
}
|
|
|
|
// ── Active Tab Panel ──
|
|
|
|
/**
|
|
* Gets the active tab panel. Ant Design keeps inactive tab panels mounted
|
|
* but sets aria-hidden="true" on them. Using :not([aria-hidden="true"])
|
|
* is more reliable than :visible during tab-switch animations where both
|
|
* panels may briefly have non-zero dimensions.
|
|
*/
|
|
private get activePanel(): Locator {
|
|
return this.page
|
|
.locator('[role="tabpanel"]:not([aria-hidden="true"])')
|
|
.filter({ has: this.page.locator(SqlLabPage.SELECTORS.ACE_EDITOR) });
|
|
}
|
|
|
|
// ── Elements ──
|
|
|
|
get editor(): AceEditor {
|
|
return new AceEditor(
|
|
this.page,
|
|
this.activePanel.locator(SqlLabPage.SELECTORS.ACE_EDITOR),
|
|
);
|
|
}
|
|
|
|
get resultsGrid(): AgGrid {
|
|
return new AgGrid(
|
|
this.page,
|
|
this.activePanel
|
|
.locator(SqlLabPage.SELECTORS.SOUTH_PANE)
|
|
.locator('[role="grid"]'),
|
|
);
|
|
}
|
|
|
|
get resultsPane(): Locator {
|
|
return this.activePanel.locator(SqlLabPage.SELECTORS.SOUTH_PANE);
|
|
}
|
|
|
|
get errorAlert(): Locator {
|
|
return this.resultsPane.locator('.ant-alert-error');
|
|
}
|
|
|
|
get databaseSelector(): Button {
|
|
return new Button(
|
|
this.page,
|
|
this.page.locator(
|
|
`${SqlLabPage.SELECTORS.LEFT_BAR} ${SqlLabPage.SELECTORS.DATABASE_SELECTOR}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
get runQueryButton(): Button {
|
|
return new Button(
|
|
this.page,
|
|
this.activePanel.locator(SqlLabPage.SELECTORS.RUN_QUERY_BUTTON),
|
|
);
|
|
}
|
|
|
|
get saveButton(): Button {
|
|
return new Button(
|
|
this.page,
|
|
this.activePanel.locator(SqlLabPage.SELECTORS.SAVE_BUTTON),
|
|
);
|
|
}
|
|
|
|
get saveDatasetButton(): Button {
|
|
return new Button(
|
|
this.page,
|
|
this.activePanel.locator(SqlLabPage.SELECTORS.SAVE_DATASET_BUTTON),
|
|
);
|
|
}
|
|
|
|
get createChartButton(): Button {
|
|
return new Button(
|
|
this.page,
|
|
this.activePanel.locator(SqlLabPage.SELECTORS.EXPLORE_RESULTS_BUTTON),
|
|
);
|
|
}
|
|
|
|
// ── Editor Convenience ──
|
|
|
|
async setQuery(sql: string): Promise<void> {
|
|
await this.editor.setText(sql);
|
|
}
|
|
|
|
async getQuery(): Promise<string> {
|
|
return this.editor.getText();
|
|
}
|
|
|
|
// ── Tab Management ──
|
|
|
|
async getTabCount(): Promise<number> {
|
|
return this.editorTabs.getTabCount();
|
|
}
|
|
|
|
async getTabNames(): Promise<string[]> {
|
|
return this.editorTabs.getTabNames();
|
|
}
|
|
|
|
async getActiveTabName(): Promise<string> {
|
|
return this.editorTabs.getActiveTabName();
|
|
}
|
|
|
|
async addTab(): Promise<void> {
|
|
await this.editorTabs.addTab();
|
|
}
|
|
|
|
async addTabByShortcut(): Promise<void> {
|
|
const modifier = process.platform === 'win32' ? 'Control+q' : 'Control+t';
|
|
await this.page.keyboard.press(modifier);
|
|
}
|
|
|
|
async closeLastTab(): Promise<void> {
|
|
const countBefore = await this.getTabCount();
|
|
await this.editorTabs.removeLastTab();
|
|
// Wait for tab count to decrease
|
|
await this.page.waitForFunction(
|
|
([selector, expected]) => {
|
|
const container = document.querySelector(selector);
|
|
if (!container) return false;
|
|
const nav = container.querySelector(':scope > .ant-tabs-nav');
|
|
if (!nav) return false;
|
|
return nav.querySelectorAll('.ant-tabs-tab').length === expected;
|
|
},
|
|
[SqlLabPage.SELECTORS.SQL_EDITOR_TABS, countBefore - 1] as const,
|
|
{ timeout: TIMEOUT.UI_TRANSITION },
|
|
);
|
|
}
|
|
|
|
getTab(name: string): Locator {
|
|
return this.editorTabs.getTab(name);
|
|
}
|
|
|
|
// ── Database Selection (Left Sidebar) ──
|
|
|
|
async selectDatabase(dbName: string): Promise<void> {
|
|
await this.databaseSelector.click();
|
|
|
|
const popover = new Popover(this.page);
|
|
await popover.waitForVisible();
|
|
|
|
// Target the .ant-select wrapper (not the combobox input) because the
|
|
// selection-item overlay intercepts pointer events on the input.
|
|
const dbSelect = popover.element
|
|
.locator(SqlLabPage.SELECTORS.DATABASE_SELECTOR)
|
|
.locator('.ant-select')
|
|
.first();
|
|
const select = new Select(this.page, dbSelect);
|
|
await select.selectOption(dbName);
|
|
|
|
await popover.getButton('Select').click();
|
|
await popover
|
|
.waitForHidden({ timeout: TIMEOUT.UI_TRANSITION })
|
|
.catch(error => {
|
|
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Query Execution ──
|
|
|
|
/**
|
|
* Sets SQL, runs the query, and waits for the API response.
|
|
* Also observes the QueryStatusBar (.ant-steps) loading indicator to
|
|
* confirm the UI entered the execution cycle — this unmounts the old
|
|
* results grid, so waitForQueryResults() can trust that any grid it
|
|
* finds afterward contains data from THIS execution.
|
|
*/
|
|
async executeQuery(sql: string): Promise<Response> {
|
|
await this.setQuery(sql);
|
|
const responsePromise = waitForPost(this.page, 'api/v1/sqllab/execute/', {
|
|
timeout: TIMEOUT.QUERY_EXECUTION,
|
|
});
|
|
// Start observing the loading indicator BEFORE clicking Run so we
|
|
// catch it even for fast queries. QueryStatusBar (.ant-steps) appears
|
|
// when SQL Lab enters the running state and unmounts the results grid.
|
|
const loadingStarted = this.resultsPane
|
|
.locator('.ant-steps')
|
|
.waitFor({ state: 'visible', timeout: TIMEOUT.QUERY_EXECUTION });
|
|
await this.runQueryButton.click();
|
|
const [, response] = await Promise.all([loadingStarted, responsePromise]);
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Wait for fresh query results to render in the AG Grid.
|
|
* Waits for the QueryStatusBar to disappear first, proving the execution
|
|
* cycle completed and React rendered the post-query grid.
|
|
* @param expectHeader - A column header that must be visible before returning.
|
|
* @param options.timeout - How long to wait (default: TIMEOUT.QUERY_EXECUTION)
|
|
*/
|
|
async waitForQueryResults(
|
|
expectHeader: string,
|
|
options?: { timeout?: number },
|
|
): Promise<void> {
|
|
const timeout = options?.timeout ?? TIMEOUT.QUERY_EXECUTION;
|
|
// Wait for QueryStatusBar to disappear — proves the loading → ready
|
|
// transition completed. If already hidden (fast query finished before
|
|
// this call), resolves immediately since executeQuery() already observed
|
|
// the loading state appear.
|
|
await this.resultsPane
|
|
.locator('.ant-steps')
|
|
.waitFor({ state: 'hidden', timeout });
|
|
const grid = this.resultsGrid.element;
|
|
await grid.waitFor({ state: 'visible', timeout });
|
|
await grid
|
|
.locator('.ag-header-cell', { hasText: expectHeader })
|
|
.first()
|
|
.waitFor({ state: 'visible', timeout });
|
|
}
|
|
|
|
// ── Row Limit ──
|
|
|
|
async getRowLimit(): Promise<string> {
|
|
const text = await this.activePanel
|
|
.locator(SqlLabPage.SELECTORS.LIMIT_DROPDOWN)
|
|
.textContent();
|
|
return text?.trim() ?? '';
|
|
}
|
|
|
|
/**
|
|
* Set the row limit via the Limit dropdown in the active panel.
|
|
* @param limit - The menu item label to select (e.g., "10", "100")
|
|
*/
|
|
async setRowLimit(limit: string): Promise<void> {
|
|
await this.activePanel.locator(SqlLabPage.SELECTORS.LIMIT_DROPDOWN).click();
|
|
await this.page.getByRole('menuitem', { name: limit, exact: true }).click();
|
|
}
|
|
}
|