diff --git a/tests/suites/tenant/diagnostics/diagnostics.test.ts b/tests/suites/tenant/diagnostics/diagnostics.test.ts index bfc93c604..8591324de 100644 --- a/tests/suites/tenant/diagnostics/diagnostics.test.ts +++ b/tests/suites/tenant/diagnostics/diagnostics.test.ts @@ -3,7 +3,7 @@ import {expect, test} from '@playwright/test'; import {dsVslotsSchema, tenantName} from '../../../utils/constants'; import {NavigationTabs, TenantPage} from '../TenantPage'; import {longRunningQuery} from '../constants'; -import {QueryEditor} from '../queryEditor/QueryEditor'; +import {QueryEditor} from '../queryEditor/models/QueryEditor'; import {Diagnostics, DiagnosticsTab, QueriesSwitch} from './Diagnostics'; diff --git a/tests/suites/tenant/queryEditor/models/NewSqlDropdownMenu.ts b/tests/suites/tenant/queryEditor/models/NewSqlDropdownMenu.ts new file mode 100644 index 000000000..407ac0d3d --- /dev/null +++ b/tests/suites/tenant/queryEditor/models/NewSqlDropdownMenu.ts @@ -0,0 +1,58 @@ +import type {Locator, Page} from '@playwright/test'; + +import {VISIBILITY_TIMEOUT} from '../../TenantPage'; + +export enum TemplateCategory { + Tables = 'Tables', + Topics = 'Topics', + AsyncReplication = 'Async replication', + CDC = 'Change data capture', + Users = 'Users', +} + +export enum AsyncReplicationTemplates { + Create = 'Create async replication', + Alter = 'Alter async replication', + Drop = 'Drop async replication', +} + +export class NewSqlDropdownMenu { + private dropdownButton: Locator; + private menu: Locator; + private subMenu: Locator; + + constructor(page: Page) { + this.dropdownButton = page.locator( + '.ydb-query-editor-controls .g-dropdown-menu__switcher-wrapper button', + ); + this.menu = page.locator('.g-dropdown-menu__menu'); + this.subMenu = page.locator('.g-dropdown-menu__sub-menu'); + } + + async clickNewSqlButton() { + await this.dropdownButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await this.dropdownButton.click(); + } + + async hoverCategory(category: TemplateCategory) { + const categoryItem = this.menu.getByRole('menuitem').filter({hasText: category}); + await categoryItem.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await categoryItem.hover(); + } + + async selectTemplate(template: AsyncReplicationTemplates) { + const templateItem = this.subMenu.getByRole('menuitem').filter({hasText: template}); + await templateItem.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await templateItem.click(); + } + + async isMenuVisible() { + await this.menu.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async isSubMenuVisible() { + await this.subMenu.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } +} diff --git a/tests/suites/tenant/queryEditor/QueryEditor.ts b/tests/suites/tenant/queryEditor/models/QueryEditor.ts similarity index 58% rename from tests/suites/tenant/queryEditor/QueryEditor.ts rename to tests/suites/tenant/queryEditor/models/QueryEditor.ts index b15c1a706..aec62a2ab 100644 --- a/tests/suites/tenant/queryEditor/QueryEditor.ts +++ b/tests/suites/tenant/queryEditor/models/QueryEditor.ts @@ -1,6 +1,11 @@ import type {Locator, Page} from '@playwright/test'; -import {VISIBILITY_TIMEOUT} from '../TenantPage'; +import {VISIBILITY_TIMEOUT} from '../../TenantPage'; + +import {QueryTabsNavigation} from './QueryTabsNavigation'; +import {PaneWrapper, ResultTable} from './ResultTable'; +import {SavedQueriesTable} from './SavedQueriesTable'; +import {SettingsDialog} from './SettingsDialog'; export enum QueryMode { YQLScript = 'YQL Script', @@ -35,175 +40,16 @@ export enum QueryTabs { Saved = 'Saved', } -export class QueryTabsNavigation { - private tabsContainer: Locator; - - constructor(page: Page) { - this.tabsContainer = page.locator('.ydb-query__tabs'); - } - - async selectTab(tabName: QueryTabs) { - const tab = this.tabsContainer.locator(`role=tab[name="${tabName}"]`); - await tab.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - await tab.click(); - } - - async isTabSelected(tabName: QueryTabs): Promise { - const tab = this.tabsContainer.locator(`role=tab[name="${tabName}"]`); - await tab.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - const isSelected = await tab.getAttribute('aria-selected'); - return isSelected === 'true'; - } - - async getTabHref(tabName: QueryTabs): Promise { - const link = this.tabsContainer.locator(`a:has(div[role="tab"][title="${tabName}"])`); - await link.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - return link.getAttribute('href'); - } -} - -export class SettingsDialog { - private dialog: Locator; - private page: Page; - - constructor(page: Page) { - this.page = page; - this.dialog = page.locator('.ydb-query-settings-dialog'); - } - - async changeQueryMode(mode: QueryMode) { - const dropdown = this.dialog.locator( - '.ydb-query-settings-dialog__control-wrapper_queryMode', - ); - await dropdown.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - await dropdown.click(); - const popup = this.page.locator('.ydb-query-settings-select__popup'); - await popup.getByText(mode).first().click(); - await this.page.waitForTimeout(1000); - } - - async changeTransactionMode(level: string) { - const dropdown = this.dialog.locator( - '.ydb-query-settings-dialog__control-wrapper_transactionMode', - ); - await dropdown.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - await dropdown.click(); - const popup = this.page.locator('.ydb-query-settings-select__popup'); - await popup.getByText(level).first().click(); - await this.page.waitForTimeout(1000); - } - - async changeStatsLevel(mode: string) { - const dropdown = this.dialog.locator( - '.ydb-query-settings-dialog__control-wrapper_statisticsMode', - ); - await dropdown.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - await dropdown.click(); - const popup = this.page.locator('.ydb-query-settings-select__popup'); - await popup.getByText(mode).first().click(); - await this.page.waitForTimeout(1000); - } - - async changeLimitRows(limitRows: number) { - const limitRowsInput = this.dialog.locator('.ydb-query-settings-dialog__limit-rows input'); - await limitRowsInput.fill(limitRows.toString()); - await this.page.waitForTimeout(1000); - } - - async clickButton(buttonName: ButtonNames) { - const button = this.dialog.getByRole('button', {name: buttonName}); - await button.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - await button.click(); - } - - async isVisible() { - await this.dialog.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - return true; - } - - async isHidden() { - await this.dialog.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); - return true; - } -} - -class PaneWrapper { - paneWrapper: Locator; - private radioButton: Locator; - - constructor(page: Page) { - this.paneWrapper = page.locator('.query-editor__pane-wrapper'); - this.radioButton = this.paneWrapper.locator('.g-radio-button'); - } - - async selectTab(tabName: ResultTabNames) { - const tab = this.radioButton.getByLabel(tabName); - await tab.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - await tab.click(); - } -} - -export class ResultTable { - private table: Locator; - private preview: Locator; - private resultHead: Locator; - - constructor(selector: Locator) { - this.table = selector.locator('.ydb-query-execute-result__result'); - this.preview = selector.locator('.kv-preview__result'); - this.resultHead = selector.locator('.ydb-query-execute-result__result-head'); - } - - async isVisible() { - await this.table.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - return true; - } - - async isHidden() { - await this.table.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); - return true; - } - - async isPreviewVisible() { - await this.preview.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - return true; - } - - async isPreviewHidden() { - await this.preview.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); - return true; - } - - async getRowCount() { - const rows = this.table.locator('tr'); - return rows.count(); - } - - async getCellValue(row: number, col: number) { - const cell = this.table.locator(`tr:nth-child(${row}) td:nth-child(${col})`); - return cell.innerText(); - } - - async isResultHeaderHidden() { - await this.resultHead.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); - return true; - } - - async getResultHeadText() { - await this.resultHead.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); - return this.resultHead.innerText(); - } -} - export class QueryEditor { settingsDialog: SettingsDialog; paneWrapper: PaneWrapper; queryTabs: QueryTabsNavigation; resultTable: ResultTable; + savedQueries: SavedQueriesTable; + editorTextArea: Locator; private page: Page; private selector: Locator; - private editorTextArea: Locator; private runButton: Locator; private explainButton: Locator; private stopButton: Locator; @@ -236,6 +82,7 @@ export class QueryEditor { this.resultTable = new ResultTable(this.selector); this.paneWrapper = new PaneWrapper(page); this.queryTabs = new QueryTabsNavigation(page); + this.savedQueries = new SavedQueriesTable(page); } async run(query: string, mode: QueryMode) { @@ -390,4 +237,20 @@ export class QueryEditor { await this.indicatorIcon.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); return true; } + + async waitForStatus(expectedStatus: string, timeout = VISIBILITY_TIMEOUT) { + await this.executionStatus.waitFor({state: 'visible', timeout}); + + // Keep checking status until it matches or times out + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + const status = await this.executionStatus.innerText(); + if (status === expectedStatus) { + return true; + } + await this.page.waitForTimeout(100); // Small delay between checks + } + + throw new Error(`Status did not change to ${expectedStatus} within ${timeout}ms`); + } } diff --git a/tests/suites/tenant/queryEditor/models/QueryTabsNavigation.ts b/tests/suites/tenant/queryEditor/models/QueryTabsNavigation.ts new file mode 100644 index 000000000..e31c4a793 --- /dev/null +++ b/tests/suites/tenant/queryEditor/models/QueryTabsNavigation.ts @@ -0,0 +1,32 @@ +import type {Locator, Page} from '@playwright/test'; + +import {VISIBILITY_TIMEOUT} from '../../TenantPage'; + +import type {QueryTabs} from './QueryEditor'; + +export class QueryTabsNavigation { + private tabsContainer: Locator; + + constructor(page: Page) { + this.tabsContainer = page.locator('.ydb-query__tabs'); + } + + async selectTab(tabName: QueryTabs) { + const tab = this.tabsContainer.locator(`role=tab[name="${tabName}"]`); + await tab.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await tab.click(); + } + + async isTabSelected(tabName: QueryTabs): Promise { + const tab = this.tabsContainer.locator(`role=tab[name="${tabName}"]`); + await tab.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + const isSelected = await tab.getAttribute('aria-selected'); + return isSelected === 'true'; + } + + async getTabHref(tabName: QueryTabs): Promise { + const link = this.tabsContainer.locator(`a:has(div[role="tab"][title="${tabName}"])`); + await link.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return link.getAttribute('href'); + } +} diff --git a/tests/suites/tenant/queryEditor/models/ResultTable.ts b/tests/suites/tenant/queryEditor/models/ResultTable.ts new file mode 100644 index 000000000..4417d34b4 --- /dev/null +++ b/tests/suites/tenant/queryEditor/models/ResultTable.ts @@ -0,0 +1,73 @@ +import type {Locator, Page} from '@playwright/test'; + +import {VISIBILITY_TIMEOUT} from '../../TenantPage'; + +import type {ResultTabNames} from './QueryEditor'; + +export class PaneWrapper { + paneWrapper: Locator; + private radioButton: Locator; + + constructor(page: Page) { + this.paneWrapper = page.locator('.query-editor__pane-wrapper'); + this.radioButton = this.paneWrapper.locator('.g-radio-button'); + } + + async selectTab(tabName: ResultTabNames) { + const tab = this.radioButton.getByLabel(tabName); + await tab.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await tab.click(); + } +} + +export class ResultTable { + private table: Locator; + private preview: Locator; + private resultHead: Locator; + + constructor(selector: Locator) { + this.table = selector.locator('.ydb-query-execute-result__result'); + this.preview = selector.locator('.kv-preview__result'); + this.resultHead = selector.locator('.ydb-query-execute-result__result-head'); + } + + async isVisible() { + await this.table.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async isHidden() { + await this.table.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async isPreviewVisible() { + await this.preview.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async isPreviewHidden() { + await this.preview.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async getRowCount() { + const rows = this.table.locator('tr'); + return rows.count(); + } + + async getCellValue(row: number, col: number) { + const cell = this.table.locator(`tr:nth-child(${row}) td:nth-child(${col})`); + return cell.innerText(); + } + + async isResultHeaderHidden() { + await this.resultHead.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async getResultHeadText() { + await this.resultHead.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return this.resultHead.innerText(); + } +} diff --git a/tests/suites/tenant/queryEditor/models/SaveQueryDialog.ts b/tests/suites/tenant/queryEditor/models/SaveQueryDialog.ts new file mode 100644 index 000000000..8a902ef4a --- /dev/null +++ b/tests/suites/tenant/queryEditor/models/SaveQueryDialog.ts @@ -0,0 +1,37 @@ +import type {Locator, Page} from '@playwright/test'; + +import {VISIBILITY_TIMEOUT} from '../../TenantPage'; + +export class SaveQueryDialog { + private dialogBody: Locator; + private dialogFooter: Locator; + + constructor(page: Page) { + this.dialogBody = page.locator('.ydb-save-query__dialog-body'); + this.dialogFooter = page.locator('.ydb-save-query__dialog-body + .g-dialog-footer'); + } + + async setQueryName(name: string) { + const input = this.dialogBody.locator('#queryName'); + await input.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + // Ensure input is ready for interaction + await input.click(); + await input.clear(); + await input.fill(name); + } + + async clickSave() { + const saveButton = this.dialogFooter.getByRole('button', {name: 'Save', exact: true}); + await saveButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await saveButton.click(); + } + + async isVisible() { + try { + await this.dialogBody.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } catch { + return false; + } + } +} diff --git a/tests/suites/tenant/queryEditor/models/SavedQueriesTable.ts b/tests/suites/tenant/queryEditor/models/SavedQueriesTable.ts new file mode 100644 index 000000000..980700794 --- /dev/null +++ b/tests/suites/tenant/queryEditor/models/SavedQueriesTable.ts @@ -0,0 +1,87 @@ +import type {Locator, Page} from '@playwright/test'; + +import {VISIBILITY_TIMEOUT} from '../../TenantPage'; + +export class SavedQueriesTable { + private page: Page; + private container: Locator; + private searchInput: Locator; + private table: Locator; + + constructor(page: Page) { + this.page = page; + this.container = page.locator('.ydb-saved-queries'); + this.searchInput = this.container.locator('.ydb-saved-queries__search input'); + this.table = this.container.locator('.data-table'); + } + + async search(text: string) { + await this.searchInput.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await this.searchInput.fill(text); + } + + async getQueryRow(name: string) { + return this.table.locator('.ydb-saved-queries__row', { + has: this.page.locator(`.ydb-saved-queries__query-name:has-text("${name}")`), + }); + } + + async editQuery(name: string) { + const row = await this.getQueryRow(name); + const editButton = row.locator('button:has(svg)').first(); + await editButton.click(); + } + + async deleteQuery(name: string) { + const row = await this.getQueryRow(name); + const deleteButton = row.locator('button:has(svg)').nth(1); + await deleteButton.click(); + } + + async getQueryText(name: string) { + const row = await this.getQueryRow(name); + return row.locator('.ydb-saved-queries__query-body').innerText(); + } + + async getQueryNames(): Promise { + const names = await this.table.locator('.ydb-saved-queries__query-name').allInnerTexts(); + return names; + } + + async getRow(index: number) { + const row = this.table.locator('.ydb-saved-queries__row').nth(index); + const name = await row.locator('.ydb-saved-queries__query-name').innerText(); + const query = await row.locator('.ydb-saved-queries__query-body').innerText(); + return { + name, + query, + element: row, + }; + } + + async getRowByName(name: string) { + const rows = this.table.locator('.ydb-saved-queries__row'); + const count = await rows.count(); + + for (let i = 0; i < count; i++) { + const row = await this.getRow(i); + if (row.name === name) { + return row; + } + } + return null; + } + + async waitForRow(name: string) { + const row = this.table.locator('.ydb-saved-queries__row', { + has: this.page.locator(`.ydb-saved-queries__query-name:has-text("${name}")`), + }); + await row.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return row; + } + + async isVisible() { + await this.container.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } +} diff --git a/tests/suites/tenant/queryEditor/models/SettingsDialog.ts b/tests/suites/tenant/queryEditor/models/SettingsDialog.ts new file mode 100644 index 000000000..4aacf4321 --- /dev/null +++ b/tests/suites/tenant/queryEditor/models/SettingsDialog.ts @@ -0,0 +1,70 @@ +import type {Locator, Page} from '@playwright/test'; + +import {VISIBILITY_TIMEOUT} from '../../TenantPage'; + +import type {ButtonNames, QueryMode} from './QueryEditor'; + +export class SettingsDialog { + private dialog: Locator; + private page: Page; + + constructor(page: Page) { + this.page = page; + this.dialog = page.locator('.ydb-query-settings-dialog'); + } + + async changeQueryMode(mode: QueryMode) { + const dropdown = this.dialog.locator( + '.ydb-query-settings-dialog__control-wrapper_queryMode', + ); + await dropdown.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await dropdown.click(); + const popup = this.page.locator('.ydb-query-settings-select__popup'); + await popup.getByText(mode).first().click(); + await this.page.waitForTimeout(1000); + } + + async changeTransactionMode(level: string) { + const dropdown = this.dialog.locator( + '.ydb-query-settings-dialog__control-wrapper_transactionMode', + ); + await dropdown.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await dropdown.click(); + const popup = this.page.locator('.ydb-query-settings-select__popup'); + await popup.getByText(level).first().click(); + await this.page.waitForTimeout(1000); + } + + async changeStatsLevel(mode: string) { + const dropdown = this.dialog.locator( + '.ydb-query-settings-dialog__control-wrapper_statisticsMode', + ); + await dropdown.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await dropdown.click(); + const popup = this.page.locator('.ydb-query-settings-select__popup'); + await popup.getByText(mode).first().click(); + await this.page.waitForTimeout(1000); + } + + async changeLimitRows(limitRows: number) { + const limitRowsInput = this.dialog.locator('.ydb-query-settings-dialog__limit-rows input'); + await limitRowsInput.fill(limitRows.toString()); + await this.page.waitForTimeout(1000); + } + + async clickButton(buttonName: ButtonNames) { + const button = this.dialog.getByRole('button', {name: buttonName}); + await button.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await button.click(); + } + + async isVisible() { + await this.dialog.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async isHidden() { + await this.dialog.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); + return true; + } +} diff --git a/tests/suites/tenant/queryEditor/models/UnsavedChangesModal.ts b/tests/suites/tenant/queryEditor/models/UnsavedChangesModal.ts new file mode 100644 index 000000000..1611ad8f0 --- /dev/null +++ b/tests/suites/tenant/queryEditor/models/UnsavedChangesModal.ts @@ -0,0 +1,45 @@ +import type {Locator, Page} from '@playwright/test'; + +import {VISIBILITY_TIMEOUT} from '../../TenantPage'; + +export class UnsavedChangesModal { + private modal: Locator; + + constructor(page: Page) { + this.modal = page.locator('.confirmation-dialog'); + } + + async clickSaveQuery() { + const saveButton = this.modal.getByRole('button', {name: 'Save query'}); + await saveButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await saveButton.click(); + } + + async clickDontSave() { + const dontSaveButton = this.modal.getByRole('button', {name: "Don't save"}); + await dontSaveButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await dontSaveButton.click(); + } + + async clickCancel() { + const cancelButton = this.modal.getByRole('button', {name: 'Cancel'}); + await cancelButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await cancelButton.click(); + } + + async clickClose() { + const closeButton = this.modal.locator('.g-dialog-btn-close__btn'); + await closeButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await closeButton.click(); + } + + async isVisible() { + await this.modal.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async isHidden() { + await this.modal.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); + return true; + } +} diff --git a/tests/suites/tenant/queryEditor/planToSvg.test.ts b/tests/suites/tenant/queryEditor/planToSvg.test.ts index d32238454..32fa5b05e 100644 --- a/tests/suites/tenant/queryEditor/planToSvg.test.ts +++ b/tests/suites/tenant/queryEditor/planToSvg.test.ts @@ -4,7 +4,7 @@ import {tenantName} from '../../../utils/constants'; import {toggleExperiment} from '../../../utils/toggleExperiment'; import {TenantPage} from '../TenantPage'; -import {ButtonNames, QueryEditor} from './QueryEditor'; +import {ButtonNames, QueryEditor} from './models/QueryEditor'; test.describe('Test Plan to SVG functionality', async () => { const testQuery = 'SELECT 1;'; // Simple query that will generate a plan diff --git a/tests/suites/tenant/queryEditor/queryEditor.test.ts b/tests/suites/tenant/queryEditor/queryEditor.test.ts index 4e5022fa6..73724606b 100644 --- a/tests/suites/tenant/queryEditor/queryEditor.test.ts +++ b/tests/suites/tenant/queryEditor/queryEditor.test.ts @@ -11,7 +11,7 @@ import { QueryMode, QueryTabs, ResultTabNames, -} from './QueryEditor'; +} from './models/QueryEditor'; test.describe('Test Query Editor', async () => { const testQuery = 'SELECT 1, 2, 3, 4, 5;'; @@ -73,9 +73,7 @@ test.describe('Test Query Editor', async () => { await queryEditor.setQuery(invalidQuery); await queryEditor.clickRunButton(); - const statusElement = await queryEditor.getExecutionStatus(); - await expect(statusElement).toBe('Failed'); - + await expect(queryEditor.waitForStatus('Failed')).resolves.toBe(true); const errorMessage = await queryEditor.getErrorMessage(); await expect(errorMessage).toContain('Column references are not allowed without FROM'); }); @@ -123,13 +121,9 @@ test.describe('Test Query Editor', async () => { await queryEditor.clickRunButton(); await expect(queryEditor.isStopButtonVisible()).resolves.toBe(true); - await queryEditor.clickStopButton(); - await page.waitForTimeout(1000); // Wait for the editor to initialize - // Check for a message or indicator that the query was stopped - const statusElement = await queryEditor.getExecutionStatus(); - await expect(statusElement).toBe('Stopped'); + await expect(queryEditor.waitForStatus('Stopped')).resolves.toBe(true); }); test('Stop button is not visible for quick queries', async ({page}) => { @@ -239,4 +233,12 @@ test.describe('Test Query Editor', async () => { await queryEditor.clickRunButton(); await expect(queryEditor.resultTable.getResultHeadText()).resolves.toBe('Truncated(1)'); }); + + test('Query execution status changes correctly', async ({page}) => { + const queryEditor = new QueryEditor(page); + await queryEditor.setQuery(testQuery); + await queryEditor.clickRunButton(); + + await expect(queryEditor.waitForStatus('Completed')).resolves.toBe(true); + }); }); diff --git a/tests/suites/tenant/queryEditor/querySettings.test.ts b/tests/suites/tenant/queryEditor/querySettings.test.ts index 62f9e9b9f..816463685 100644 --- a/tests/suites/tenant/queryEditor/querySettings.test.ts +++ b/tests/suites/tenant/queryEditor/querySettings.test.ts @@ -4,7 +4,7 @@ import {tenantName} from '../../../utils/constants'; import {TenantPage, VISIBILITY_TIMEOUT} from '../TenantPage'; import {longRunningQuery} from '../constants'; -import {ButtonNames, QueryEditor, QueryMode} from './QueryEditor'; +import {ButtonNames, QueryEditor, QueryMode} from './models/QueryEditor'; test.describe('Test Query Settings', async () => { const testQuery = 'SELECT 1, 2, 3, 4, 5;'; diff --git a/tests/suites/tenant/queryEditor/queryStatus.test.ts b/tests/suites/tenant/queryEditor/queryStatus.test.ts index 251fceca6..ea27ada9b 100644 --- a/tests/suites/tenant/queryEditor/queryStatus.test.ts +++ b/tests/suites/tenant/queryEditor/queryStatus.test.ts @@ -4,7 +4,7 @@ import {tenantName} from '../../../utils/constants'; import {TenantPage} from '../TenantPage'; import {longRunningQuery} from '../constants'; -import {QueryEditor} from './QueryEditor'; +import {QueryEditor} from './models/QueryEditor'; test.describe('Test Query Execution Status', async () => { const testQuery = 'SELECT 1;'; // Simple query that will generate a plan diff --git a/tests/suites/tenant/queryEditor/queryTemplates.test.ts b/tests/suites/tenant/queryEditor/queryTemplates.test.ts new file mode 100644 index 000000000..3ad8d4acf --- /dev/null +++ b/tests/suites/tenant/queryEditor/queryTemplates.test.ts @@ -0,0 +1,161 @@ +import {expect, test} from '@playwright/test'; + +import {dsVslotsSchema, dsVslotsTableName, tenantName} from '../../../utils/constants'; +import {TenantPage} from '../TenantPage'; +import {ObjectSummary} from '../summary/ObjectSummary'; +import {RowTableAction} from '../summary/types'; + +import { + AsyncReplicationTemplates, + NewSqlDropdownMenu, + TemplateCategory, +} from './models/NewSqlDropdownMenu'; +import {QueryEditor, QueryTabs} from './models/QueryEditor'; +import {SaveQueryDialog} from './models/SaveQueryDialog'; +import {SavedQueriesTable} from './models/SavedQueriesTable'; +import {UnsavedChangesModal} from './models/UnsavedChangesModal'; + +test.describe('Query Templates', () => { + test.beforeEach(async ({page}) => { + const pageQueryParams = { + schema: dsVslotsSchema, + database: tenantName, + general: 'query', + }; + const tenantPage = new TenantPage(page); + await tenantPage.goto(pageQueryParams); + }); + + test('Unsaved changes modal appears when switching between templates', async ({page}) => { + const objectSummary = new ObjectSummary(page); + const unsavedChangesModal = new UnsavedChangesModal(page); + + // First action - Add index + await objectSummary.clickActionMenuItem(dsVslotsTableName, RowTableAction.AddIndex); + await page.waitForTimeout(500); + + // Try to switch to Select query + await objectSummary.clickActionMenuItem(dsVslotsTableName, RowTableAction.SelectQuery); + await page.waitForTimeout(500); + + // Verify unsaved changes modal appears + await expect(unsavedChangesModal.isVisible()).resolves.toBe(true); + }); + + test('Cancel button in unsaved changes modal preserves editor content', async ({page}) => { + const objectSummary = new ObjectSummary(page); + const unsavedChangesModal = new UnsavedChangesModal(page); + const queryEditor = new QueryEditor(page); + + // First action - Add index + await objectSummary.clickActionMenuItem(dsVslotsTableName, RowTableAction.AddIndex); + await page.waitForTimeout(500); + + // Store initial editor content + const initialContent = await queryEditor.editorTextArea.inputValue(); + + // Try to switch to Select query + await objectSummary.clickActionMenuItem(dsVslotsTableName, RowTableAction.SelectQuery); + await page.waitForTimeout(500); + + // Click Cancel in the modal + await unsavedChangesModal.clickCancel(); + + // Verify editor content remains unchanged + await expect(queryEditor.editorTextArea).toHaveValue(initialContent); + }); + + test('Dont save button in unsaved changes modal allows text to change', async ({page}) => { + const objectSummary = new ObjectSummary(page); + const unsavedChangesModal = new UnsavedChangesModal(page); + const queryEditor = new QueryEditor(page); + + // First action - Add index + await objectSummary.clickActionMenuItem(dsVslotsTableName, RowTableAction.AddIndex); + await page.waitForTimeout(500); + + // Store initial editor content + const initialContent = await queryEditor.editorTextArea.inputValue(); + + // Try to switch to Select query + await objectSummary.clickActionMenuItem(dsVslotsTableName, RowTableAction.SelectQuery); + await page.waitForTimeout(500); + + // Click Don't save in the modal + await unsavedChangesModal.clickDontSave(); + + // Verify editor content has changed + const newContent = await queryEditor.editorTextArea.inputValue(); + expect(newContent).not.toBe(initialContent); + expect(newContent).not.toBe(''); + }); + + test('Save query flow saves query and shows it in Saved tab', async ({page}) => { + const objectSummary = new ObjectSummary(page); + const unsavedChangesModal = new UnsavedChangesModal(page); + const queryEditor = new QueryEditor(page); + const saveQueryDialog = new SaveQueryDialog(page); + const savedQueriesTable = new SavedQueriesTable(page); + + // First action - Add index + await objectSummary.clickActionMenuItem(dsVslotsTableName, RowTableAction.AddIndex); + await page.waitForTimeout(500); + + // Try to switch to Select query + await objectSummary.clickActionMenuItem(dsVslotsTableName, RowTableAction.SelectQuery); + await page.waitForTimeout(500); + + // Click Save query in the modal + await unsavedChangesModal.clickSaveQuery(); + + // Verify save query dialog appears and fill the name + await expect(saveQueryDialog.isVisible()).resolves.toBe(true); + const queryName = `Test Query ${Date.now()}`; + await saveQueryDialog.setQueryName(queryName); + await saveQueryDialog.clickSave(); + + // Switch to Saved tab and verify query exists + await queryEditor.queryTabs.selectTab(QueryTabs.Saved); + await page.waitForTimeout(500); + await savedQueriesTable.isVisible(); + const row = await savedQueriesTable.waitForRow(queryName); + expect(row).not.toBe(null); + }); + + test('New SQL dropdown menu works correctly', async ({page}) => { + const newSqlDropdown = new NewSqlDropdownMenu(page); + const queryEditor = new QueryEditor(page); + + // Open dropdown menu + await newSqlDropdown.clickNewSqlButton(); + await expect(newSqlDropdown.isMenuVisible()).resolves.toBe(true); + + // Hover over Async replication category + await newSqlDropdown.hoverCategory(TemplateCategory.AsyncReplication); + await expect(newSqlDropdown.isSubMenuVisible()).resolves.toBe(true); + + // Select Create template + await newSqlDropdown.selectTemplate(AsyncReplicationTemplates.Create); + + expect(queryEditor.editorTextArea).not.toBeEmpty(); + }); + + test('Template selection shows unsaved changes warning when editor has content', async ({ + page, + }) => { + const newSqlDropdown = new NewSqlDropdownMenu(page); + const queryEditor = new QueryEditor(page); + const unsavedChangesModal = new UnsavedChangesModal(page); + + // First set some content + await queryEditor.setQuery('SELECT 1;'); + + // Try to select a template + await newSqlDropdown.clickNewSqlButton(); + await newSqlDropdown.hoverCategory(TemplateCategory.AsyncReplication); + await newSqlDropdown.selectTemplate(AsyncReplicationTemplates.Create); + + // Verify unsaved changes modal appears + await expect(unsavedChangesModal.isVisible()).resolves.toBe(true); + }); +}); diff --git a/tests/suites/tenant/queryHistory/queryHistory.test.ts b/tests/suites/tenant/queryHistory/queryHistory.test.ts index 19301b5d7..78919fab2 100644 --- a/tests/suites/tenant/queryHistory/queryHistory.test.ts +++ b/tests/suites/tenant/queryHistory/queryHistory.test.ts @@ -2,7 +2,7 @@ import {expect, test} from '@playwright/test'; import {tenantName} from '../../../utils/constants'; import {TenantPage, VISIBILITY_TIMEOUT} from '../TenantPage'; -import {QueryEditor, QueryMode} from '../queryEditor/QueryEditor'; +import {QueryEditor, QueryMode} from '../queryEditor/models/QueryEditor'; import executeQueryWithKeybinding from './utils'; diff --git a/tests/suites/tenant/summary/ActionsMenu.ts b/tests/suites/tenant/summary/ActionsMenu.ts new file mode 100644 index 000000000..9b8d6859b --- /dev/null +++ b/tests/suites/tenant/summary/ActionsMenu.ts @@ -0,0 +1,45 @@ +import type {Locator} from '@playwright/test'; + +import {VISIBILITY_TIMEOUT} from '../TenantPage'; + +import {RowTableAction} from './types'; + +export class ActionsMenu { + private menu: Locator; + constructor(menu: Locator) { + this.menu = menu; + } + + async isVisible(): Promise { + try { + await this.menu.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } catch (error) { + return false; + } + } + + async getItems(): Promise { + const items = this.menu.locator('.g-menu__item-content'); + return items.allTextContents(); + } + + async clickItem(itemText: string): Promise { + const menuItem = this.menu.locator(`.g-menu__item-content:text("${itemText}")`); + await menuItem.click(); + } + + async isItemSelected(itemText: string): Promise { + const menuItem = this.menu.locator(`.g-menu__item:has-text("${itemText}")`); + const className = (await menuItem.getAttribute('class')) || ''; + return className.includes('g-menu__item_selected'); + } + + async getTableTemplates(): Promise { + const items = this.menu.locator('.g-menu__item-content'); + const contents = await items.allTextContents(); + return contents.filter((content): content is RowTableAction => + Object.values(RowTableAction).includes(content as RowTableAction), + ); + } +} diff --git a/tests/suites/tenant/summary/ObjectSummary.ts b/tests/suites/tenant/summary/ObjectSummary.ts index 2d5dbed23..22966d789 100644 --- a/tests/suites/tenant/summary/ObjectSummary.ts +++ b/tests/suites/tenant/summary/ObjectSummary.ts @@ -2,6 +2,9 @@ import type {Locator, Page} from '@playwright/test'; import {VISIBILITY_TIMEOUT} from '../TenantPage'; +import {ActionsMenu} from './ActionsMenu'; +import type {RowTableAction} from './types'; + export enum ObjectSummaryTab { Overview = 'Overview', ACL = 'ACL', @@ -14,6 +17,7 @@ export class ObjectSummary { private tree: Locator; private treeRows: Locator; private primaryKeys: Locator; + private actionsMenu: ActionsMenu; constructor(page: Page) { this.tree = page.locator('.ydb-object-summary__tree'); @@ -21,6 +25,7 @@ export class ObjectSummary { this.tabs = page.locator('.ydb-object-summary__tabs'); this.schemaViewer = page.locator('.schema-viewer'); this.primaryKeys = page.locator('.schema-viewer__keys_type_primary'); + this.actionsMenu = new ActionsMenu(page.locator('.g-popup.g-popup_open')); } async isTreeVisible() { @@ -65,6 +70,26 @@ export class ObjectSummary { await openPreviewIcon.click(); } + async clickActionsButton(text: string): Promise { + const treeItem = this.treeRows.filter({hasText: text}).first(); + await treeItem.hover(); + + const actionsIcon = treeItem.locator('.g-dropdown-menu__switcher-button'); + await actionsIcon.click(); + } + + async isActionsMenuVisible(): Promise { + return this.actionsMenu.isVisible(); + } + + async getActionsMenuItems(): Promise { + return this.actionsMenu.getItems(); + } + + async clickActionsMenuItem(itemText: string): Promise { + await this.actionsMenu.clickItem(itemText); + } + async clickTab(tabName: ObjectSummaryTab): Promise { const tab = this.tabs.locator(`.ydb-object-summary__tab:has-text("${tabName}")`); await tab.click(); @@ -75,4 +100,13 @@ export class ObjectSummary { const keysText = (await keysElement.textContent()) || ''; return keysText.split(', ').map((key) => key.trim()); } + + async getTableTemplates(): Promise { + return this.actionsMenu.getTableTemplates(); + } + + async clickActionMenuItem(treeItemText: string, menuItemText: string): Promise { + await this.clickActionsButton(treeItemText); + await this.actionsMenu.clickItem(menuItemText); + } } diff --git a/tests/suites/tenant/summary/objectSummary.test.ts b/tests/suites/tenant/summary/objectSummary.test.ts index 37f914964..d09efdeb7 100644 --- a/tests/suites/tenant/summary/objectSummary.test.ts +++ b/tests/suites/tenant/summary/objectSummary.test.ts @@ -2,9 +2,10 @@ import {expect, test} from '@playwright/test'; import {dsVslotsSchema, dsVslotsTableName, tenantName} from '../../../utils/constants'; import {TenantPage} from '../TenantPage'; -import {QueryEditor} from '../queryEditor/QueryEditor'; +import {QueryEditor} from '../queryEditor/models/QueryEditor'; import {ObjectSummary, ObjectSummaryTab} from './ObjectSummary'; +import {RowTableAction} from './types'; test.describe('Object Summary', async () => { test.beforeEach(async ({page}) => { @@ -17,7 +18,7 @@ test.describe('Object Summary', async () => { await tenantPage.goto(pageQueryParams); }); - test('Open Preview icon appears on hover for "test" tree item', async ({page}) => { + test('Open Preview icon appears on hover for "dv_slots" tree item', async ({page}) => { const objectSummary = new ObjectSummary(page); await expect(objectSummary.isTreeVisible()).resolves.toBe(true); @@ -60,4 +61,24 @@ test.describe('Object Summary', async () => { 'VSlotId', ]); }); + + test('Actions dropdown menu opens and contains expected items', async ({page}) => { + const objectSummary = new ObjectSummary(page); + await expect(objectSummary.isTreeVisible()).resolves.toBe(true); + + await objectSummary.clickActionsButton(dsVslotsTableName); + await expect(objectSummary.isActionsMenuVisible()).resolves.toBe(true); + }); + + test('Can click menu items in actions dropdown', async ({page}) => { + const objectSummary = new ObjectSummary(page); + const queryEditor = new QueryEditor(page); + + await expect(objectSummary.isTreeVisible()).resolves.toBe(true); + await objectSummary.clickActionMenuItem(dsVslotsTableName, RowTableAction.AddIndex); + await page.waitForTimeout(500); + + await expect(queryEditor.editorTextArea).toBeVisible(); + await expect(queryEditor.editorTextArea).not.toBeEmpty(); + }); }); diff --git a/tests/suites/tenant/summary/types.ts b/tests/suites/tenant/summary/types.ts new file mode 100644 index 000000000..f0e074d08 --- /dev/null +++ b/tests/suites/tenant/summary/types.ts @@ -0,0 +1,9 @@ +export enum RowTableAction { + CopyPath = 'Copy path', + AlterTable = 'Alter table...', + DropTable = 'Drop table...', + SelectQuery = 'Select query...', + UpsertQuery = 'Upsert query...', + AddIndex = 'Add index...', + CreateChangefeed = 'Create changefeed...', +}