diff --git a/e2e_tests/helpers/apiEndpoints.ts b/e2e_tests/helpers/apiEndpoints.ts index cf64e76bf..0bebe6fae 100644 --- a/e2e_tests/helpers/apiEndpoints.ts +++ b/e2e_tests/helpers/apiEndpoints.ts @@ -27,6 +27,7 @@ const apiEndpoints = { }, realtimeanalytics: { queriesSearch: '/v1/realtimeanalytics/queries:search', + sessions: '/v1/realtimeanalytics/sessions', sessionsStart: '/v1/realtimeanalytics/sessions:start', sessionsStop: '/v1/realtimeanalytics/sessions:stop', }, diff --git a/e2e_tests/helpers/mocks.helper.ts b/e2e_tests/helpers/mocks.helper.ts index 75654e4ac..e4cb7b292 100644 --- a/e2e_tests/helpers/mocks.helper.ts +++ b/e2e_tests/helpers/mocks.helper.ts @@ -59,6 +59,25 @@ export default class mocksHelper { await this.page.route(apiEndpoints.management.services, fulfillNoServices); }; + mockRealTimeAnalyticsSessions = async (): Promise => { + const sessions = Array.from({ length: 26 }, (_, index) => ({ + cluster_name: `mock-cluster-${String(index + 1).padStart(2, '0')}`, + collect_interval: '2s', + service_id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + service_name: `mock-service-${String(index + 1).padStart(2, '0')}`, + start_time: '2026-01-01T00:00:00Z', + status: 'SESSION_STATUS_RUNNING', + })); + + await this.page.route(apiEndpoints.realtimeanalytics.sessions, (route) => + route.fulfill({ + body: JSON.stringify({ sessions }), + contentType: 'application/json', + status: 200, + }), + ); + }; + mockSnoozedUpdate = async (updateVersion: string): Promise<{ snoozedAt: number }> => { const state = { snoozedAt: 0, snoozedVersion: '' }; diff --git a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts index accb11051..9fa214475 100644 --- a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts +++ b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts @@ -27,6 +27,7 @@ export default class RealTimeAnalyticsPage extends BasePage { this.page.getByTestId(realTimeTableTestId).locator(`//tbody//tr[position()=${rowIndex}]`), rowByQueryText: (queryText: string) => this.page.getByTestId(realTimeTableTestId).locator(`tr`, { hasText: queryText }), + rowsPerPageOption: (pageSize: string) => this.page.getByRole('option', { exact: true, name: pageSize }), }; buttons = { allSessions: this.page.getByTestId('overview-table-all-sessions-button'), @@ -47,26 +48,33 @@ export default class RealTimeAnalyticsPage extends BasePage { elements = { detailsOperationId: this.page.getByTestId('operation-id-value'), detailsPane: this.page.getByTestId('query-details-pane'), + durationCells: this.page.getByTestId(realTimeTableTestId).locator('tbody tr td:nth-child(4)'), elapsedTimeColumnHeader: this.page .getByTestId(realTimeTableTestId) - .getByText('Elapsed time', { exact: true }), - hostColumnHeader: this.page.getByTestId(realTimeTableTestId).getByText('Host', { exact: true }), + .getByRole('columnheader', { name: /Elapsed time/ }), + hostColumnHeader: this.page.getByTestId(realTimeTableTestId).getByRole('columnheader', { name: /Host/ }), noQueriesAvailable: this.builders.rowByIndex('1').getByRole('alert', { name: 'No queries available' }), queryTextColumnHeader: this.page .getByTestId(realTimeTableTestId) .getByText('Query text', { exact: true }), realTimeTable: this.page.getByTestId(realTimeTableTestId), realTimeTableRow: this.page.getByTestId(realTimeTableTestId).locator('tbody tr'), + selectedSessionRows: this.page.locator('tbody input[aria-label="Toggle select row"]:checked'), + sessionRows: this.page.locator('tbody > tr'), + sessionRowSelectionCheckboxes: this.page.locator('tbody input[aria-label="Toggle select row"]'), }; inputs = { clusterService: this.page.locator('input[name = "service"]'), filterByQueryText: this.page.getByTitle('Filter by Query text'), + maximumDuration: this.page.getByRole('textbox', { name: 'Max' }), + minimumDuration: this.page.getByRole('textbox', { name: 'Min' }), realTimeServiceInput: this.page.getByTestId('realtime-service-input'), + rowsLimit: this.page.getByRole('combobox', { name: 'Rows per page' }), }; messages = {}; clickElapsedTimeHeader = async () => { - await this.elements.elapsedTimeColumnHeader.click(); + await this.elements.elapsedTimeColumnHeader.getByText('Elapsed time', { exact: true }).click(); }; clickHostHeader = async () => { @@ -152,6 +160,12 @@ export default class RealTimeAnalyticsPage extends BasePage { await this.buttons.filters.click(); }; + openFiltersIfHidden = async () => { + if (!(await this.inputs.filterByQueryText.isVisible())) { + await this.openFilters(); + } + }; + selectClusterService = async () => { await this.inputs.clusterService.click(); await this.page.getByRole('option').first().click(); diff --git a/e2e_tests/pages/qan/storedMetrics/storedMetrics.page.ts b/e2e_tests/pages/qan/storedMetrics/storedMetrics.page.ts index 4ab53f86e..2ff104467 100644 --- a/e2e_tests/pages/qan/storedMetrics/storedMetrics.page.ts +++ b/e2e_tests/pages/qan/storedMetrics/storedMetrics.page.ts @@ -8,8 +8,16 @@ const serviceTypes: AccessServiceType[] = ['mongodb', 'mysql', 'postgresql']; export default class StoredMetricsPage extends BasePage { readonly url = 'graph/d/pmm-qan/pmm-query-analytics'; builders = { + paginationItem: (pageNumber: string) => + this.grafanaIframe().getByRole('listitem', { exact: true, name: pageNumber }), serviceTypeCheckbox: (serviceType: string) => this.grafanaIframe().getByTestId(`filter-checkbox-${serviceType}`), + serviceTypeFilter: (serviceType: string) => + this.grafanaIframe().locator(`input[name="service_type;${serviceType}"]`), + serviceTypeLabel: (serviceType: string) => + this.grafanaIframe() + .locator('label') + .filter({ has: this.builders.serviceTypeFilter(serviceType) }), }; buttons = {}; elements = { @@ -21,7 +29,9 @@ export default class StoredMetricsPage extends BasePage { spinner: this.grafanaIframe().locator('//*[@data-testid="Spinner"]'), totalCount: this.grafanaIframe().locator('//*[@data-testid="qan-total-items"]'), }; - inputs = {}; + inputs = { + search: this.grafanaIframe().locator('input[name="search"]'), + }; messages = {}; verifyOnlyServiceTypeVisible = async (expected: AccessServiceType) => { diff --git a/e2e_tests/tests/qan/rta/overview.test.ts b/e2e_tests/tests/qan/rta/overview.test.ts index 7c4b103b6..18fa33e31 100644 --- a/e2e_tests/tests/qan/rta/overview.test.ts +++ b/e2e_tests/tests/qan/rta/overview.test.ts @@ -241,3 +241,94 @@ pmmTest('PMM-T2252 Verify RTA overview CSV export @rta', async ({ page, queryAna expect(csvOperationIds).toEqual(uiOperationIds); }); }); + +pmmTest( + 'PMM-T2265 Verify RTA overview table state is stored in the URL and restored after refresh @rta', + async ({ page, queryAnalytics }) => { + const { rta } = queryAnalytics; + const expectedServiceIds = new URL(page.url()).searchParams.getAll('serviceIds'); + + expect(expectedServiceIds).toHaveLength(2); + + await rta.buttons.pauseRealTimeAnalytics.click(); + await rta.filterQueriesByText('db.runCommand'); + await rta.inputs.rowsLimit.click(); + await rta.builders.rowsPerPageOption('10').click(); + await rta.clickElapsedTimeHeader(); + + await expect + .poll(() => new URL(page.url()).searchParams.get('overview.f.queryText')) + .toBe('db.runCommand'); + await expect.poll(() => new URL(page.url()).searchParams.get('overview.pageSize')).toBe('10'); + await expect.poll(() => new URL(page.url()).searchParams.get('overview.sort')).not.toBeNull(); + + expect(new URL(page.url()).searchParams.getAll('serviceIds')).toEqual(expectedServiceIds); + + await page.reload(); + await rta.elements.realTimeTable.waitFor({ state: 'visible' }); + await rta.openFiltersIfHidden(); + + await expect(rta.inputs.filterByQueryText).toHaveValue('db.runCommand'); + await expect(rta.inputs.rowsLimit).toHaveText('10'); + await expect(rta.elements.elapsedTimeColumnHeader).toHaveAccessibleName( + /Elapsed time Sorted by Elapsed time descending/, + ); + expect(new URL(page.url()).searchParams.getAll('serviceIds')).toEqual(expectedServiceIds); + }, +); + +pmmTest( + 'PMM-T2266 Verify RTA elapsed-time decimal filter and URL restoration @rta', + async ({ page, queryAnalytics }) => { + const { rta } = queryAnalytics; + + await rta.elements.realTimeTableRow.first().waitFor({ state: 'visible' }); + await rta.buttons.pauseRealTimeAnalytics.click(); + await rta.openFilters(); + + const rowsBeforeFilter = await rta.elements.realTimeTableRow.count(); + const durations = (await rta.elements.durationCells.allTextContents()).map(Number.parseFloat); + const shortestDuration = Math.min(...durations); + const longestDuration = Math.max(...durations); + const decimalMinimum = String(Number(((shortestDuration + longestDuration) / 2).toFixed(2))); + const decimalMaximum = String(longestDuration); + + expect(longestDuration).toBeGreaterThan(shortestDuration); + + await rta.inputs.minimumDuration.fill(decimalMinimum); + await rta.inputs.maximumDuration.fill(decimalMaximum); + await expect + .poll(async () => { + const values = await rta.elements.durationCells.allTextContents(); + + return ( + values.length > 0 && + values.length < rowsBeforeFilter && + values.every( + (value) => + Number.parseFloat(value) >= Number(decimalMinimum) && + Number.parseFloat(value) <= Number(decimalMaximum), + ) + ); + }) + .toBeTruthy(); + + const durationParameterName = 'overview.f.queryExecutionDurationMs'; + + await expect + .poll(() => new URL(page.url()).searchParams.get(durationParameterName)) + .toEqual(expect.stringContaining(decimalMinimum)); + await expect + .poll(() => new URL(page.url()).searchParams.get(durationParameterName)) + .toEqual(expect.stringContaining(decimalMaximum)); + + const durationParameterValue = new URL(page.url()).searchParams.get(durationParameterName); + + await page.reload(); + await rta.openFiltersIfHidden(); + + await expect(rta.inputs.minimumDuration).toHaveValue(decimalMinimum); + await expect(rta.inputs.maximumDuration).toHaveValue(decimalMaximum); + expect(new URL(page.url()).searchParams.get(durationParameterName)).toBe(durationParameterValue); + }, +); diff --git a/e2e_tests/tests/qan/rta/session.test.ts b/e2e_tests/tests/qan/rta/session.test.ts index d86fb803b..9aade9d03 100644 --- a/e2e_tests/tests/qan/rta/session.test.ts +++ b/e2e_tests/tests/qan/rta/session.test.ts @@ -51,3 +51,23 @@ pmmTest('PMM-T2182 Verify overview loads when session exists @rta', async ({ api await expect(queryAnalytics.rta.elements.realTimeTable).toBeVisible(); }); }); + +pmmTest( + 'PMM-T2267 Verify RTA sessions page size is stored in the URL and restored after refresh @rta', + async ({ mocks, page, queryAnalytics }) => { + await mocks.mockRealTimeAnalyticsSessions(); + await page.goto(queryAnalytics.rtaSessionsUrl); + + const { rta } = queryAnalytics; + const initialSize = await rta.inputs.rowsLimit.textContent(); + const selectedSize = initialSize === '10' ? '25' : '10'; + + await rta.inputs.rowsLimit.click(); + await rta.builders.rowsPerPageOption(selectedSize).click(); + await expect.poll(() => new URL(page.url()).searchParams.get('sessions.pageSize')).toBe(selectedSize); + + await page.reload(); + await expect(rta.inputs.rowsLimit).toHaveText(selectedSize); + await expect(rta.elements.sessionRows).toHaveCount(Number(selectedSize)); + }, +); diff --git a/e2e_tests/tests/qan/storedMetrics/urlState.test.ts b/e2e_tests/tests/qan/storedMetrics/urlState.test.ts new file mode 100644 index 000000000..0eb78dcc6 --- /dev/null +++ b/e2e_tests/tests/qan/storedMetrics/urlState.test.ts @@ -0,0 +1,70 @@ +import pmmTest from '@fixtures/pmmTest'; +import { Timeouts } from '@helpers/timeouts'; +import StoredMetricsPage from '@pages/qan/storedMetrics/storedMetrics.page'; +import { expect, type ConsoleMessage } from '@playwright/test'; + +pmmTest.beforeEach(async ({ grafanaHelper, page, queryAnalytics }) => { + await grafanaHelper.authorize(); + await page.goto(queryAnalytics.url); + await queryAnalytics.storedMetrics.elements.iframe.waitFor({ + state: 'visible', + timeout: Timeouts.THIRTY_SECONDS, + }); +}); + +pmmTest( + 'PMM-T2268 Verify QAN shared URL restores filters and pagination @rta', + async ({ context, page, queryAnalytics }) => { + const { storedMetrics } = queryAnalytics; + const mongoDbLabel = storedMetrics.builders.serviceTypeLabel('mongodb'); + const errors: string[] = []; + + const collectErrors = (message: ConsoleMessage) => { + if (message.type() === 'error') errors.push(message.text()); + }; + + page.on('console', collectErrors); + await mongoDbLabel.scrollIntoViewIfNeeded(); + await mongoDbLabel.click(); + + const secondPaginationItem = storedMetrics.builders.paginationItem('2'); + + await expect(secondPaginationItem).toBeVisible({ timeout: Timeouts.THIRTY_SECONDS }); + await secondPaginationItem.click(); + + for (let index = 0; index < 4; index++) { + await mongoDbLabel.click(); + } + + for (const pageNumber of ['1', '2', '1', '2']) { + await storedMetrics.builders.paginationItem(pageNumber).click(); + } + + for (const value of ['a', 'ab', 'abc', '']) { + await storedMetrics.inputs.search.fill(value); + } + + // eslint-disable-next-line playwright/no-wait-for-timeout -- allow debounce and console errors to settle + await page.waitForTimeout(Timeouts.HALF_SECOND); + + await expect.poll(() => new URL(page.url()).searchParams.has('dimensionSearchText')).toBeFalsy(); + await expect.poll(() => new URL(page.url()).searchParams.get('page_number')).toBe('2'); + await expect.poll(() => new URL(page.url()).searchParams.getAll('var-service_type')).toContain('mongodb'); + + const sharedUrl = page.url(); + const sharedPage = await context.newPage(); + + await sharedPage.goto(sharedUrl); + + const sharedStoredMetrics = new StoredMetricsPage(sharedPage); + + await expect(sharedStoredMetrics.builders.serviceTypeFilter('mongodb')).toBeChecked({ + timeout: Timeouts.THIRTY_SECONDS, + }); + await expect(sharedStoredMetrics.builders.paginationItem('2')).toHaveClass(/ant-pagination-item-active/); + + page.off('console', collectErrors); + expect(errors).toEqual([]); + await sharedPage.close(); + }, +);