/** * 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 { await this.page.goto(URL.SQLLAB, { waitUntil: 'domcontentloaded' }); } async waitForPageLoad(options?: { timeout?: number }): Promise { // 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 { 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 { // Page-global check: are there ANY editors in the DOM (any tab)? const anyEditor = this.page.locator(SqlLabPage.SELECTORS.ACE_EDITOR); let tabSyncPromise: Promise | 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 { await this.editor.setText(sql); } async getQuery(): Promise { return this.editor.getText(); } // ── Tab Management ── async getTabCount(): Promise { return this.editorTabs.getTabCount(); } async getTabNames(): Promise { return this.editorTabs.getTabNames(); } async getActiveTabName(): Promise { return this.editorTabs.getActiveTabName(); } async addTab(): Promise { await this.editorTabs.addTab(); } async addTabByShortcut(): Promise { const modifier = process.platform === 'win32' ? 'Control+q' : 'Control+t'; await this.page.keyboard.press(modifier); } async closeLastTab(): Promise { 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 { 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 { 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 { 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 { 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 { await this.activePanel.locator(SqlLabPage.SELECTORS.LIMIT_DROPDOWN).click(); await this.page.getByRole('menuitem', { name: limit, exact: true }).click(); } }