From 575e80dcaf53a7b9529372d7a2bbf0b536abfaff Mon Sep 17 00:00:00 2001 From: theTibi Date: Sun, 2 Aug 2026 12:28:02 +0200 Subject: [PATCH 1/9] PMM-15284 Add MySQL RTA QA coverage Real-Time Analytics now supports MySQL (PMM-15283, percona/pmm#5509); add QA automation verified against the PR-4417-11f4346 feature build: - MySQLHelper workload generator (labeled SELECT SLEEP() statements visible in sys.x$processlist) with a mySqlDbHelper fixture; defaults match the QA framework ps setup, overridable via MYSQL_* env vars. - mysql.test.ts (@rta, 6 tests): overview shows a running MySQL query with host/database/user; details pane shows MySQL attributes and the raw processlist row; Hide-COMMIT toggle; Database/User faceted multi-select filters; MongoDB and MySQL rows side by side with payload-specific details; CSV export contains the MySQL columns. Mocked search responses use the snake_case wire format. - CLI: add/change/remove flow for rta-mysql-agent in the percona-server suite, gated on pmm-client >= 3.9.0. - RealTimeAnalyticsPage: update position-based column locators for the new layout (Database 3, User 4, Operation ID 5, Elapsed time 6 pinned right) and add locators for the new controls; fix the T2252 export filename assertion (mongodb_rta_export_ -> rta_export_). - CI: the @rta feature-build job also provisions ps=8.4. - Add mysql2 dependency. --- .github/workflows/fb-e2e-suite.yml | 2 +- cli/tests/perconaMySqlServer.spec.ts | 48 +++ e2e_tests/fixtures/pmmTest.ts | 16 + e2e_tests/helpers/mysql.helper.ts | 54 +++ e2e_tests/package-lock.json | 138 ++++++- e2e_tests/package.json | 1 + .../pages/qan/rta/realTimeAnalytics.page.ts | 41 ++- e2e_tests/tests/qan/rta/mysql.test.ts | 336 ++++++++++++++++++ e2e_tests/tests/qan/rta/overview.test.ts | 2 +- 9 files changed, 632 insertions(+), 6 deletions(-) create mode 100644 e2e_tests/helpers/mysql.helper.ts create mode 100644 e2e_tests/tests/qan/rta/mysql.test.ts diff --git a/.github/workflows/fb-e2e-suite.yml b/.github/workflows/fb-e2e-suite.yml index 4e8dfc523..5ba1537e6 100644 --- a/.github/workflows/fb-e2e-suite.yml +++ b/.github/workflows/fb-e2e-suite.yml @@ -409,7 +409,7 @@ jobs: pmm_client_version: ${{ inputs.pmm_client_version || 'latest-tarball' }} launchable_confidence: ${{ inputs.launchable_confidence || '100%' }} pmm_qa_branch: ${{ github.event_name == 'pull_request' && github.head_ref || (inputs.pmm_qa_branch || 'main') }} - setup_services: '--database psmdb' + setup_services: '--database psmdb --database ps=8.4' pmm_test_flag: '@rta' workers: 1 diff --git a/cli/tests/perconaMySqlServer.spec.ts b/cli/tests/perconaMySqlServer.spec.ts index e2abe3346..6f3d34d7f 100644 --- a/cli/tests/perconaMySqlServer.spec.ts +++ b/cli/tests/perconaMySqlServer.spec.ts @@ -230,5 +230,53 @@ test.describe('PMM Client CLI tests for Percona Server Database', { tag: '@perco `Expected pmm-admin to honor --connection-timeout=5s, got ${output.durationMs.toFixed(0)} ms`, ).toBeGreaterThan(5_000); }); + + test('Verify adding, changing and removing MySQL RTA Agent in pmm-admin CLI', async ({ }) => { + test.skip(adminVersion < 9, 'MySQL RTA is available from pmm-client version 3.9.0'); + + const serviceName = 'rta_mysql_service'; + + await test.step('add MySQL service for the RTA agent', async () => { + const output = await cli.exec(`docker exec ${containerName} pmm-admin add mysql --query-source=perfschema --username=${MYSQL_USER} --password=${MYSQL_PASSWORD} ${serviceName} ${ipPort}`); + await output.assertSuccess(); + await output.outContains('MySQL Service added.'); + }); + + const serviceId = (await cli.exec(`docker exec ${containerName} pmm-admin list | grep "${serviceName}" | awk -F" " '{print $4}'`)).getStdOutLines()[0]; + const pmmAgentId = (await cli.exec(`docker exec ${containerName} pmm-admin list | grep pmm_agent | awk -F" " '{print $3}'`)).getStdOutLines()[0]; + + await test.step('add MySQL RTA agent and verify it reaches Running', async () => { + const output = await cli.exec(`docker exec ${containerName} pmm-admin inventory add agent rta-mysql-agent ${pmmAgentId} ${serviceId} ${MYSQL_USER} --password=${MYSQL_PASSWORD}`); + await output.outContains('Real-Time Analytics MySQL agent added.'); + + await expect(async () => { + const pmmAdminListOutput = await cli.exec(`docker exec ${containerName} pmm-admin list`); + + await pmmAdminListOutput.outContains('rta_mysql_agent Running'); + }).toPass({ intervals: [1_000], timeout: 60_000 }); + + await expect(async () => { + const pmmAdminListOutput = await cli.exec(`docker exec ${containerName} pmm-admin inventory list agents --service-id=${serviceId}`); + + await pmmAdminListOutput.outContains('rta_mysql_agent Running'); + }).toPass({ intervals: [1_000], timeout: 60_000 }); + }); + + const rtaAgentId = (await cli.exec(`docker exec ${containerName} pmm-admin list | grep rta_mysql_agent | awk -F" " '{print $3}'`)).getStdOutLines()[0]; + + await test.step('change MySQL RTA agent log level and collect interval', async () => { + const output = await cli.exec(`docker exec ${containerName} pmm-admin inventory change agent rta-mysql-agent ${rtaAgentId} --log-level=debug --collect-interval=5s`); + await output.outContains('Real-Time Analytics MySQL agent configuration updated.'); + await output.outContains('changed log level to debug'); + await output.outContains('changed collect interval to 5s'); + }); + + await test.step('remove MySQL RTA agent and service', async () => { + const output = await cli.exec(`docker exec ${containerName} pmm-admin inventory remove agent ${rtaAgentId}`); + await output.outContains('Agent removed.'); + + await removeMySQLService(containerName, serviceName); + }); + }); }); diff --git a/e2e_tests/fixtures/pmmTest.ts b/e2e_tests/fixtures/pmmTest.ts index 7109bbdb4..a6439ee96 100644 --- a/e2e_tests/fixtures/pmmTest.ts +++ b/e2e_tests/fixtures/pmmTest.ts @@ -17,6 +17,7 @@ import QueryAnalytics from '@pages/qan/queryAnalytics.page'; import RealTimeAnalyticsPage from '@pages/qan/rta/realTimeAnalytics.page'; import NodesPage from '@pages/inventory/nodes.page'; import MongoDBHelper from '@helpers/mongodb.helper'; +import MySQLHelper from '@helpers/mysql.helper'; import VacuumDashboard from '@pages/dashboards/postgresql/vacuumDashboard'; import apiEndpoints from '@helpers/apiEndpoints'; import SettingsPage from '@pages/ha/settings.page'; @@ -31,6 +32,7 @@ const pmmTest = base.extend<{ dashboard: Dashboard; grafanaHelper: GrafanaHelper; mongoDbHelper: MongoDBHelper; + mySqlDbHelper: MySQLHelper; api: Api; qanStoredMetrics: QanStoredMetrics; urlHelper: UrlHelper; @@ -122,6 +124,20 @@ const pmmTest = base.extend<{ await use(mongoDbHelper); }, + mySqlDbHelper: async ({}, use) => { + // Defaults match the QA framework ps setup (host port 3317 -> sandbox 3307, + // dbdeployer msandbox credentials); override via env for local environments. + const mySqlDbHelper = new MySQLHelper({ + database: process.env.MYSQL_DATABASE || 'test', + host: process.env.MYSQL_HOST || '127.0.0.1', + password: process.env.MYSQL_PASSWORD || 'msandbox', + port: Number(process.env.MYSQL_PORT) || 3_317, + username: process.env.MYSQL_USER || 'msandbox', + }); + + await use(mySqlDbHelper); + await mySqlDbHelper.close(); + }, nodesPage: async ({ page }, use) => await use(new NodesPage(page)), portalRemoval: async ({ page }, use) => { const portalRemoval = new PortalRemoval(page); diff --git a/e2e_tests/helpers/mysql.helper.ts b/e2e_tests/helpers/mysql.helper.ts new file mode 100644 index 000000000..8161b0952 --- /dev/null +++ b/e2e_tests/helpers/mysql.helper.ts @@ -0,0 +1,54 @@ +import mysql, { Pool } from 'mysql2/promise'; +import { Timeouts } from './timeouts'; + +interface MySqlConfig { + host?: string; + port: number; + username: string; + password: string; + database?: string; +} + +export default class MySQLHelper { + private pool: Pool; + + constructor(config: MySqlConfig) { + this.pool = mysql.createPool({ + // Each long-running query occupies a connection for its whole duration, + // so allow several to run concurrently for tests that need multiple rows. + connectionLimit: 10, + connectTimeout: Timeouts.THIRTY_SECONDS, + database: config.database, + host: config.host || '127.0.0.1', + password: config.password, + port: config.port, + user: config.username, + }); + } + + close = async () => await this.pool.end(); + + runQuery = async (sql: string) => await this.pool.query(sql); + + /** + * Simulates a long-running query visible in sys.x$processlist (and therefore in RTA). + * SLEEP() keeps the statement in the processlist for the whole delay while consuming + * nothing; the label is embedded as a string literal so the RTA query text can be + * filtered by it (comments could be stripped by intermediaries, literals cannot). + * + * @param options.queryLabel - string selected by the query; use it to find this query in RTA + * @returns Resolves when the statement finishes + */ + simulateLongRunningQuery = async ( + options: { + delayMs?: number; + queryLabel?: string; + } = {}, + ) => { + const { delayMs = Timeouts.TEN_SECONDS, queryLabel = 'rta-simulated-query' } = options; + const escapedLabel = queryLabel.replace(/\\/g, '\\\\').replace(/'/g, "''"); + const delaySeconds = Math.max(1, Math.ceil(delayMs / 1_000)); + + return this.pool.query(`SELECT '${escapedLabel}', SLEEP(${delaySeconds})`); + }; +} diff --git a/e2e_tests/package-lock.json b/e2e_tests/package-lock.json index 8b0dc4c45..ae1886943 100644 --- a/e2e_tests/package-lock.json +++ b/e2e_tests/package-lock.json @@ -8,7 +8,6 @@ "name": "e2e_tests", "version": "1.0.0", "license": "ISC", - "hasInstallScript": true, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.1.0", "@eslint/js": "^9.37.0", @@ -32,6 +31,7 @@ "jiti": "^2.6.1", "lint-staged": "^16.2.7", "mongodb": "^7.1.0", + "mysql2": "^3.15.0", "playwright": "^1.56.0", "prettier": "^3.6.2", "shelljs": "^0.10.0", @@ -956,6 +956,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1301,6 +1311,16 @@ "dev": true, "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -1936,6 +1956,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -2085,6 +2115,23 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -2214,6 +2261,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -2550,6 +2604,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "11.2.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", @@ -2560,6 +2621,22 @@ "node": "20 || >=22" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "dev": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -2742,6 +2819,42 @@ "dev": true, "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz", + "integrity": "sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nano-spawn": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", @@ -3357,6 +3470,13 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -3511,6 +3631,22 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "dev": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", diff --git a/e2e_tests/package.json b/e2e_tests/package.json index bf94d04a7..75c160a60 100644 --- a/e2e_tests/package.json +++ b/e2e_tests/package.json @@ -35,6 +35,7 @@ "jiti": "^2.6.1", "lint-staged": "^16.2.7", "mongodb": "^7.1.0", + "mysql2": "^3.15.0", "playwright": "^1.56.0", "prettier": "^3.6.2", "shelljs": "^0.10.0", diff --git a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts index accb11051..0f6d4eadb 100644 --- a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts +++ b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts @@ -10,29 +10,36 @@ export default class RealTimeAnalyticsPage extends BasePage { readonly url = 'pmm-ui/rta/overview'; readonly refreshIntervals = ['1s', '2s', '3s', '4s', '5s'] as const; apiEndpoint = apiEndpoints.realtimeanalytics.queriesSearch; + toggles = { + hideCommit: this.page.getByTestId('overview-table-hide-commit-toggle'), + }; + // Column order: 1 Query text, 2 Host, 3 Database, 4 User, 5 Operation ID, 6 Elapsed time (pinned right). builders = { + databaseForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=3]'), detailsPaneCodeByText: (queryText: string) => this.elements.detailsPane.locator('[data-testid="query-text"], code.language-mongodb', { hasText: queryText, }), elapsedTimeForQueryByText: (queryText: string) => - this.builders.rowByQueryText(queryText).locator('//td[position()=4]'), - elapsedTimeForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=4]'), + this.builders.rowByQueryText(queryText).locator('//td[position()=6]'), + elapsedTimeForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=6]'), hostForLastRow: () => this.page.getByTestId(realTimeTableTestId).locator('tbody tr').last().locator('td').nth(1), hostForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=2]'), - operationIdForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=3]'), + operationIdForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=5]'), queryByRowIndex: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=1]'), rowByIndex: (rowIndex: string) => this.page.getByTestId(realTimeTableTestId).locator(`//tbody//tr[position()=${rowIndex}]`), rowByQueryText: (queryText: string) => this.page.getByTestId(realTimeTableTestId).locator(`tr`, { hasText: queryText }), + userForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=4]'), }; buttons = { allSessions: this.page.getByTestId('overview-table-all-sessions-button'), closeDetailsPane: this.page.getByTestId('details-pane-close-button'), detailsNextQuery: this.page.getByTestId('details-pane-next-button'), detailsPreviousQuery: this.page.getByTestId('details-pane-prev-button'), + detailsRawDataTab: this.page.getByTestId('details-pane-raw-data-tab'), export: this.page.getByTestId('overview-table-export-button'), filters: this.page.getByRole('button', { name: 'Show/Hide filters' }), nextPage: this.page.getByRole('button', { name: 'Go to next page' }), @@ -45,8 +52,17 @@ export default class RealTimeAnalyticsPage extends BasePage { stopAllSessions: this.page.getByTestId('open-stop-all-modal'), }; elements = { + databaseColumnHeader: this.page.getByTestId(realTimeTableTestId).getByText('Database', { exact: true }), + detailsCommand: this.page.getByTestId('command-value'), + detailsFullScan: this.page.getByTestId('full-scan-value'), detailsOperationId: this.page.getByTestId('operation-id-value'), detailsPane: this.page.getByTestId('query-details-pane'), + detailsProgramName: this.page.getByTestId('program-name-value'), + detailsRawData: this.page.getByTestId('query-raw-data'), + detailsRowsExamined: this.page.getByTestId('rows-examined-value'), + detailsRowsSent: this.page.getByTestId('rows-sent-value'), + detailsState: this.page.getByTestId('state-value'), + detailsUsername: this.page.getByTestId('username-value'), elapsedTimeColumnHeader: this.page .getByTestId(realTimeTableTestId) .getByText('Elapsed time', { exact: true }), @@ -57,6 +73,7 @@ export default class RealTimeAnalyticsPage extends BasePage { .getByText('Query text', { exact: true }), realTimeTable: this.page.getByTestId(realTimeTableTestId), realTimeTableRow: this.page.getByTestId(realTimeTableTestId).locator('tbody tr'), + userColumnHeader: this.page.getByTestId(realTimeTableTestId).getByText('User', { exact: true }), }; inputs = { clusterService: this.page.locator('input[name = "service"]'), @@ -77,6 +94,18 @@ export default class RealTimeAnalyticsPage extends BasePage { await this.elements.queryTextColumnHeader.click(); }; + /** + * Selects a value in a multi-select column filter (Database / User columns). + * Expects the filter row to be open already (see openFilters). + */ + filterByColumnOption = async (columnHeader: 'Database' | 'User', optionText: string) => { + await pmmTest.step(`Filter ${columnHeader} column by: ${optionText}`, async () => { + await this.page.getByRole('combobox', { name: `Filter by ${columnHeader}` }).click(); + await this.page.getByRole('option', { name: optionText }).click(); + await this.page.keyboard.press('Escape'); + }); + }; + filterQueriesByText = async (queryText: string) => { await pmmTest.step(`Filter queries by text: ${queryText}`, async () => { await this.builders.rowByIndex('1').waitFor({ state: 'visible' }); @@ -165,6 +194,12 @@ export default class RealTimeAnalyticsPage extends BasePage { await this.buttons.stopAgentsButton.waitFor({ state: 'hidden', timeout: Timeouts.THREE_SECONDS }); }; + toggleHideCommit = async () => { + await pmmTest.step('Toggle Hide COMMIT', async () => { + await this.toggles.hideCommit.click(); + }); + }; + verifyRequestInterval = async ( intervalMs: number, timeoutMs: number, diff --git a/e2e_tests/tests/qan/rta/mysql.test.ts b/e2e_tests/tests/qan/rta/mysql.test.ts new file mode 100644 index 000000000..005b53447 --- /dev/null +++ b/e2e_tests/tests/qan/rta/mysql.test.ts @@ -0,0 +1,336 @@ +import { readFile } from 'node:fs/promises'; +import pmmTest from '@fixtures/pmmTest'; +import { Timeouts } from '@helpers/timeouts'; +import apiEndpoints from '@helpers/apiEndpoints'; +import { ServiceType } from '@interfaces/inventory'; +import { expect } from '@playwright/test'; + +// Mocked responses must use the wire format (snake_case, like the real API); +// the UI camelizes them client-side via axios-case-converter. +const buildMySqlQuery = (overrides: { + database?: string; + queryId: string; + queryText: string; + username?: string; +}) => ({ + client_address: '127.0.0.1:52134', + my_sql_payload: { + command: 'Query', + database_name: overrides.database ?? 'sbtest', + db_instance_address: '127.0.0.1:3307', + full_scan: false, + program_name: 'sysbench', + rows_examined: '100', + rows_sent: '10', + username: overrides.username ?? 'sbtest@localhost', + }, + query_collect_time: new Date().toISOString(), + query_execution_duration: '2s', + query_id: overrides.queryId, + query_raw_json: JSON.stringify({ current_statement: overrides.queryText }), + query_text: overrides.queryText, + service_id: 'mock-mysql-service', + service_name: 'mock-mysql-service', +}); +const buildMongoDbQuery = (overrides: { queryId: string; queryText: string }) => ({ + client_address: '127.0.0.1:41234', + mongo_db_payload: { + client_app_name: 'mongosh', + collection: 'mycollection', + database_name: 'admin', + db_instance_address: '127.0.0.1:27017', + operation: 'query', + operation_start_time: new Date().toISOString(), + plan_summary: 'COLLSCAN', + username: 'pmm-mongo', + }, + query_collect_time: new Date().toISOString(), + query_execution_duration: '3s', + query_id: overrides.queryId, + query_raw_json: JSON.stringify({ op: 'query' }), + query_text: overrides.queryText, + service_id: 'mock-mongo-service', + service_name: 'mock-mongo-service', +}); + +pmmTest.beforeEach(async ({ api, grafanaHelper, page, queryAnalytics }) => { + await grafanaHelper.authorize(); + + const [service] = await api.inventoryApi.getServicesByType(ServiceType.mysql); + + await api.realTimeAnalyticsApi.startRealTimeAnalytics(service.service_id); + await page.goto(queryAnalytics.rta.getUrlWithServices([service.service_id])); +}); + +pmmTest( + 'Verify RTA overview shows running MySQL queries with database and user @rta', + async ({ api, mySqlDbHelper, queryAnalytics }) => { + const queryLabel = 'rta-mysql-overview'; + const [service] = await api.inventoryApi.getServicesByType(ServiceType.mysql); + + await pmmTest.step('Simulate long running MySQL query', async () => { + void mySqlDbHelper.simulateLongRunningQuery({ + delayMs: Timeouts.THIRTY_SECONDS, + queryLabel, + }); + + await expect(queryAnalytics.rta.builders.rowByQueryText(queryLabel)).toBeVisible({ + timeout: Timeouts.TEN_SECONDS, + }); + }); + + await pmmTest.step('Pause RTA and filter query list', async () => { + await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); + await queryAnalytics.rta.filterQueriesByText(queryLabel); + }); + + await pmmTest.step('Verify host, database and user columns for the MySQL row', async () => { + await expect(queryAnalytics.rta.builders.hostForRow('1')).toHaveText(service.service_name); + await expect(queryAnalytics.rta.builders.databaseForRow('1')).toHaveText('test'); + await expect(queryAnalytics.rta.builders.userForRow('1')).toContainText('msandbox'); + }); + }, +); + +pmmTest( + 'Verify MySQL query details pane shows MySQL-specific attributes and raw data @rta', + async ({ mySqlDbHelper, queryAnalytics }) => { + const queryLabel = 'rta-mysql-details'; + + await pmmTest.step('Simulate long running MySQL query', async () => { + void mySqlDbHelper.simulateLongRunningQuery({ + delayMs: Timeouts.THIRTY_SECONDS, + queryLabel, + }); + + await expect(queryAnalytics.rta.builders.rowByQueryText(queryLabel)).toBeVisible({ + timeout: Timeouts.TEN_SECONDS, + }); + }); + + await pmmTest.step('Pause RTA and open details for the MySQL query', async () => { + await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); + await queryAnalytics.rta.filterQueriesByText(queryLabel); + await queryAnalytics.rta.openDetailsForRow('1'); + }); + + await pmmTest.step('Verify MySQL-specific attributes', async () => { + await expect(queryAnalytics.rta.builders.detailsPaneCodeByText(queryLabel)).toBeVisible(); + await expect(queryAnalytics.rta.elements.detailsCommand).toHaveText('Query'); + await expect(queryAnalytics.rta.elements.detailsState).not.toBeEmpty(); + await expect(queryAnalytics.rta.elements.detailsUsername).toContainText('msandbox'); + await expect(queryAnalytics.rta.elements.detailsRowsExamined).toBeVisible(); + await expect(queryAnalytics.rta.elements.detailsRowsSent).toBeVisible(); + await expect(queryAnalytics.rta.elements.detailsFullScan).toBeVisible(); + }); + + await pmmTest.step('Verify raw data tab shows the full processlist row', async () => { + await queryAnalytics.rta.buttons.detailsRawDataTab.click(); + + await expect(queryAnalytics.rta.elements.detailsRawData).toBeVisible(); + await expect(queryAnalytics.rta.elements.detailsRawData).toContainText(queryLabel); + await expect(queryAnalytics.rta.elements.detailsRawData).toContainText('conn_id'); + }); + }, +); + +pmmTest( + 'Verify Hide COMMIT toggle filters transaction-control statements @rta', + async ({ page, queryAnalytics }) => { + await pmmTest.step('Mock RTA search response with COMMIT and SELECT queries', async () => { + await page.route(apiEndpoints.realtimeanalytics.queriesSearch, (route) => + route.fulfill({ + body: JSON.stringify({ + queries: [ + buildMySqlQuery({ queryId: '101', queryText: 'COMMIT' }), + buildMySqlQuery({ queryId: '102', queryText: 'SELECT c FROM sbtest1 WHERE id=42' }), + ], + }), + contentType: 'application/json', + status: 200, + }), + ); + await page.reload(); + + await expect(queryAnalytics.rta.builders.rowByQueryText('COMMIT')).toBeVisible({ + timeout: Timeouts.TEN_SECONDS, + }); + await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT c FROM sbtest1')).toBeVisible(); + }); + + await pmmTest.step('Enable Hide COMMIT and verify the COMMIT row disappears', async () => { + await queryAnalytics.rta.toggleHideCommit(); + + await expect(queryAnalytics.rta.builders.rowByQueryText('COMMIT')).toHaveCount(0); + await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT c FROM sbtest1')).toBeVisible(); + }); + + await pmmTest.step('Disable Hide COMMIT and verify the COMMIT row is back', async () => { + await queryAnalytics.rta.toggleHideCommit(); + + await expect(queryAnalytics.rta.builders.rowByQueryText('COMMIT')).toBeVisible(); + }); + }, +); + +pmmTest( + 'Verify Database and User multi-select filters narrow the query list @rta', + async ({ page, queryAnalytics }) => { + await pmmTest.step('Mock RTA search response with queries from two databases', async () => { + await page.route(apiEndpoints.realtimeanalytics.queriesSearch, (route) => + route.fulfill({ + body: JSON.stringify({ + queries: [ + buildMySqlQuery({ + database: 'sbtest', + queryId: '201', + queryText: 'SELECT c FROM sbtest1 WHERE id=1', + username: 'sbtest@localhost', + }), + buildMySqlQuery({ + database: 'orders', + queryId: '202', + queryText: 'SELECT id FROM orders WHERE status=1', + username: 'app@localhost', + }), + ], + }), + contentType: 'application/json', + status: 200, + }), + ); + await page.reload(); + + await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(2, { + timeout: Timeouts.TEN_SECONDS, + }); + }); + + await pmmTest.step('Filter by database and verify only matching rows remain', async () => { + await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); + await queryAnalytics.rta.openFilters(); + await queryAnalytics.rta.filterByColumnOption('Database', 'sbtest'); + + await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT c FROM sbtest1')).toBeVisible(); + await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT id FROM orders')).toHaveCount(0); + }); + + await pmmTest.step('Verify User filter options are faceted to the filtered rows', async () => { + await page.getByRole('combobox', { name: 'Filter by User' }).click(); + + // The hidden row's user is no longer offered; only users present in the + // currently filtered result set are. + await expect(page.getByRole('option', { name: 'app@localhost' })).toBeHidden(); + await page.getByRole('option', { name: 'sbtest@localhost' }).click(); + await page.keyboard.press('Escape'); + + await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT c FROM sbtest1')).toBeVisible(); + await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT id FROM orders')).toHaveCount(0); + }); + }, +); + +pmmTest( + 'Verify overview renders MongoDB and MySQL queries side by side @rta', + async ({ page, queryAnalytics }) => { + const mongoQueryText = '{ find: "mycollection", filter: { status: "active" } }'; + const mySqlQueryText = 'SELECT c FROM sbtest1 WHERE id=7'; + + await pmmTest.step('Mock RTA search response with one query per engine', async () => { + await page.route(apiEndpoints.realtimeanalytics.queriesSearch, (route) => + route.fulfill({ + body: JSON.stringify({ + queries: [ + buildMongoDbQuery({ queryId: '301', queryText: mongoQueryText }), + buildMySqlQuery({ queryId: '302', queryText: mySqlQueryText }), + ], + }), + contentType: 'application/json', + status: 200, + }), + ); + await page.reload(); + + await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(2, { + timeout: Timeouts.TEN_SECONDS, + }); + }); + + await pmmTest.step('Verify each row resolves database and user from its own payload', async () => { + await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); + + const mongoRow = queryAnalytics.rta.builders.rowByQueryText('find: "mycollection"'); + const mySqlRow = queryAnalytics.rta.builders.rowByQueryText('SELECT c FROM sbtest1'); + + await expect(mongoRow).toContainText('admin'); + await expect(mongoRow).toContainText('pmm-mongo'); + await expect(mySqlRow).toContainText('sbtest'); + await expect(mySqlRow).toContainText('sbtest@localhost'); + }); + + await pmmTest.step('Verify details pane switches payload-specific fields per engine', async () => { + await queryAnalytics.rta.builders.rowByQueryText('find: "mycollection"').click(); + await expect(queryAnalytics.rta.elements.detailsPane).toBeVisible(); + + // MongoDB-only fields visible, MySQL-only fields absent. + await expect(queryAnalytics.rta.elements.detailsPane.getByTestId('collection-value')).toBeVisible(); + await expect(queryAnalytics.rta.elements.detailsCommand).toBeHidden(); + + await queryAnalytics.rta.buttons.detailsNextQuery.click(); + + // And the other way around for the MySQL row. + await expect(queryAnalytics.rta.elements.detailsCommand).toBeVisible(); + await expect(queryAnalytics.rta.elements.detailsPane.getByTestId('collection-value')).toBeHidden(); + + await queryAnalytics.rta.buttons.closeDetailsPane.click(); + }); + }, +); + +pmmTest( + 'Verify RTA CSV export contains MySQL-specific columns @rta', + async ({ page, queryAnalytics }, testInfo) => { + await pmmTest.step('Mock RTA search response with a MySQL query', async () => { + await page.route(apiEndpoints.realtimeanalytics.queriesSearch, (route) => + route.fulfill({ + body: JSON.stringify({ + queries: [buildMySqlQuery({ queryId: '401', queryText: 'SELECT c FROM sbtest1 WHERE id=13' })], + }), + contentType: 'application/json', + status: 200, + }), + ); + await page.reload(); + + await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(1, { + timeout: Timeouts.TEN_SECONDS, + }); + }); + + await pmmTest.step('Export CSV and verify the MySQL columns and values', async () => { + await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); + + const downloadPromise = page.waitForEvent('download'); + + await queryAnalytics.rta.buttons.export.click(); + + const download = await downloadPromise; + + expect(download.suggestedFilename()).toMatch(/^rta_export_\d{8}_\d{6}\.csv$/); + + const csvPath = testInfo.outputPath(download.suggestedFilename()); + + await download.saveAs(csvPath); + + const csvContent = await readFile(csvPath, 'utf8'); + + for (const column of ['command', 'state', 'program_name', 'rows_examined', 'rows_sent', 'full_scan']) { + expect(csvContent).toContain(column); + } + + expect(csvContent).toContain('Query'); + expect(csvContent).toContain('sysbench'); + expect(csvContent).toContain('sbtest@localhost'); + }); + }, +); diff --git a/e2e_tests/tests/qan/rta/overview.test.ts b/e2e_tests/tests/qan/rta/overview.test.ts index 7c4b103b6..590fa080f 100644 --- a/e2e_tests/tests/qan/rta/overview.test.ts +++ b/e2e_tests/tests/qan/rta/overview.test.ts @@ -226,7 +226,7 @@ pmmTest('PMM-T2252 Verify RTA overview CSV export @rta', async ({ page, queryAna const fileName = download.suggestedFilename(); const csvPath = testInfo.outputPath(fileName); - expect(fileName).toMatch(/^mongodb_rta_export_\d{8}_\d{6}\.csv$/); + expect(fileName).toMatch(/^rta_export_\d{8}_\d{6}\.csv$/); await download.saveAs(csvPath); From 9d667afd281e3e734bbcfb0e3d0561bdf7b805dd Mon Sep 17 00:00:00 2001 From: theTibi Date: Sun, 2 Aug 2026 12:43:56 +0200 Subject: [PATCH 2/9] PMM-15284 Address review: exit codes, mock fidelity, fast teardown - CLI: assert the exit code alongside the output message in the RTA agent add/change/remove steps. - e2e: add the state field to the mocked MySQL payload to match the wire contract. - MySQLHelper: run long queries on dedicated tracked connections and destroy them on close(), so fixture teardown no longer waits out SLEEP() statements (the two live tests drop from ~31s to ~4s each); teardown-induced aborts are suppressed while real query failures still surface. --- cli/tests/perconaMySqlServer.spec.ts | 3 ++ e2e_tests/helpers/mysql.helper.ts | 48 ++++++++++++++++++++++----- e2e_tests/tests/qan/rta/mysql.test.ts | 1 + 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/cli/tests/perconaMySqlServer.spec.ts b/cli/tests/perconaMySqlServer.spec.ts index 6f3d34d7f..2f83f0d24 100644 --- a/cli/tests/perconaMySqlServer.spec.ts +++ b/cli/tests/perconaMySqlServer.spec.ts @@ -247,6 +247,7 @@ test.describe('PMM Client CLI tests for Percona Server Database', { tag: '@perco await test.step('add MySQL RTA agent and verify it reaches Running', async () => { const output = await cli.exec(`docker exec ${containerName} pmm-admin inventory add agent rta-mysql-agent ${pmmAgentId} ${serviceId} ${MYSQL_USER} --password=${MYSQL_PASSWORD}`); + await output.assertSuccess(); await output.outContains('Real-Time Analytics MySQL agent added.'); await expect(async () => { @@ -266,6 +267,7 @@ test.describe('PMM Client CLI tests for Percona Server Database', { tag: '@perco await test.step('change MySQL RTA agent log level and collect interval', async () => { const output = await cli.exec(`docker exec ${containerName} pmm-admin inventory change agent rta-mysql-agent ${rtaAgentId} --log-level=debug --collect-interval=5s`); + await output.assertSuccess(); await output.outContains('Real-Time Analytics MySQL agent configuration updated.'); await output.outContains('changed log level to debug'); await output.outContains('changed collect interval to 5s'); @@ -273,6 +275,7 @@ test.describe('PMM Client CLI tests for Percona Server Database', { tag: '@perco await test.step('remove MySQL RTA agent and service', async () => { const output = await cli.exec(`docker exec ${containerName} pmm-admin inventory remove agent ${rtaAgentId}`); + await output.assertSuccess(); await output.outContains('Agent removed.'); await removeMySQLService(containerName, serviceName); diff --git a/e2e_tests/helpers/mysql.helper.ts b/e2e_tests/helpers/mysql.helper.ts index 8161b0952..49a4a9791 100644 --- a/e2e_tests/helpers/mysql.helper.ts +++ b/e2e_tests/helpers/mysql.helper.ts @@ -1,4 +1,4 @@ -import mysql, { Pool } from 'mysql2/promise'; +import mysql, { Connection, Pool } from 'mysql2/promise'; import { Timeouts } from './timeouts'; interface MySqlConfig { @@ -10,23 +10,38 @@ interface MySqlConfig { } export default class MySQLHelper { + private closed = false; + private config: mysql.PoolOptions; + // Connections running intentionally long queries; destroyed on close() so + // fixture teardown does not wait for SLEEP() statements to finish. + private longRunningConnections: Connection[] = []; private pool: Pool; constructor(config: MySqlConfig) { - this.pool = mysql.createPool({ - // Each long-running query occupies a connection for its whole duration, - // so allow several to run concurrently for tests that need multiple rows. - connectionLimit: 10, + this.config = { connectTimeout: Timeouts.THIRTY_SECONDS, database: config.database, host: config.host || '127.0.0.1', password: config.password, port: config.port, user: config.username, - }); + }; + this.pool = mysql.createPool(this.config); } - close = async () => await this.pool.end(); + close = async () => { + this.closed = true; + + // destroy() drops the socket immediately instead of draining, so teardown + // is not blocked by long-running SLEEP() queries. + for (const connection of this.longRunningConnections) { + connection.destroy(); + } + + this.longRunningConnections = []; + + await this.pool.end(); + }; runQuery = async (sql: string) => await this.pool.query(sql); @@ -36,8 +51,12 @@ export default class MySQLHelper { * nothing; the label is embedded as a string literal so the RTA query text can be * filtered by it (comments could be stripped by intermediaries, literals cannot). * + * Runs on a dedicated connection that close() destroys, so tests do not have to + * wait for the full delay. Errors caused by that teardown are suppressed; anything + * else is rethrown so a query that could not run still fails the test. + * * @param options.queryLabel - string selected by the query; use it to find this query in RTA - * @returns Resolves when the statement finishes + * @returns Resolves when the statement finishes or the helper is closed */ simulateLongRunningQuery = async ( options: { @@ -48,7 +67,18 @@ export default class MySQLHelper { const { delayMs = Timeouts.TEN_SECONDS, queryLabel = 'rta-simulated-query' } = options; const escapedLabel = queryLabel.replace(/\\/g, '\\\\').replace(/'/g, "''"); const delaySeconds = Math.max(1, Math.ceil(delayMs / 1_000)); + const connection = await mysql.createConnection(this.config); + + this.longRunningConnections.push(connection); + + try { + return await connection.query(`SELECT '${escapedLabel}', SLEEP(${delaySeconds})`); + } catch (error) { + if (this.closed) { + return null; + } - return this.pool.query(`SELECT '${escapedLabel}', SLEEP(${delaySeconds})`); + throw error; + } }; } diff --git a/e2e_tests/tests/qan/rta/mysql.test.ts b/e2e_tests/tests/qan/rta/mysql.test.ts index 005b53447..e87f99e77 100644 --- a/e2e_tests/tests/qan/rta/mysql.test.ts +++ b/e2e_tests/tests/qan/rta/mysql.test.ts @@ -22,6 +22,7 @@ const buildMySqlQuery = (overrides: { program_name: 'sysbench', rows_examined: '100', rows_sent: '10', + state: 'executing', username: overrides.username ?? 'sbtest@localhost', }, query_collect_time: new Date().toISOString(), From 9f38979007347aac6cbde0435d3733b29edb4128 Mon Sep 17 00:00:00 2001 From: theTibi Date: Sun, 2 Aug 2026 12:59:50 +0200 Subject: [PATCH 3/9] Improve long-running query simulation in MySQLHelper for better RTA visibility and connection management --- e2e_tests/helpers/mysql.helper.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/e2e_tests/helpers/mysql.helper.ts b/e2e_tests/helpers/mysql.helper.ts index 49a4a9791..9ee928da9 100644 --- a/e2e_tests/helpers/mysql.helper.ts +++ b/e2e_tests/helpers/mysql.helper.ts @@ -48,8 +48,9 @@ export default class MySQLHelper { /** * Simulates a long-running query visible in sys.x$processlist (and therefore in RTA). * SLEEP() keeps the statement in the processlist for the whole delay while consuming - * nothing; the label is embedded as a string literal so the RTA query text can be - * filtered by it (comments could be stripped by intermediaries, literals cannot). + * nothing; the label is selected as a string literal so the RTA query text can be + * filtered by it. mysql2's query() interpolates placeholders client-side (no server + * prepare), so the label stays visible in the processlist statement text. * * Runs on a dedicated connection that close() destroys, so tests do not have to * wait for the full delay. Errors caused by that teardown are suppressed; anything @@ -65,20 +66,30 @@ export default class MySQLHelper { } = {}, ) => { const { delayMs = Timeouts.TEN_SECONDS, queryLabel = 'rta-simulated-query' } = options; - const escapedLabel = queryLabel.replace(/\\/g, '\\\\').replace(/'/g, "''"); const delaySeconds = Math.max(1, Math.ceil(delayMs / 1_000)); - const connection = await mysql.createConnection(this.config); - - this.longRunningConnections.push(connection); + let connection: Connection | undefined; try { - return await connection.query(`SELECT '${escapedLabel}', SLEEP(${delaySeconds})`); + connection = await mysql.createConnection(this.config); + + if (this.closed) { + return null; + } + + this.longRunningConnections.push(connection); + + return await connection.query('SELECT ?, SLEEP(?)', [queryLabel, delaySeconds]); } catch (error) { if (this.closed) { return null; } throw error; + } finally { + if (connection) { + this.longRunningConnections = this.longRunningConnections.filter((c) => c !== connection); + connection.destroy(); + } } }; } From b66dd1bce76ec76e574689be10d5d3956232bcb1 Mon Sep 17 00:00:00 2001 From: theTibi Date: Sun, 2 Aug 2026 13:54:27 +0200 Subject: [PATCH 4/9] Enhance RTA filters to support text input with lazy matching and update test cases --- .../pages/qan/rta/realTimeAnalytics.page.ts | 12 +++--- e2e_tests/tests/qan/rta/mysql.test.ts | 38 +++++++++++-------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts index 0f6d4eadb..b39f01a18 100644 --- a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts +++ b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts @@ -67,6 +67,7 @@ export default class RealTimeAnalyticsPage extends BasePage { .getByTestId(realTimeTableTestId) .getByText('Elapsed time', { exact: true }), hostColumnHeader: this.page.getByTestId(realTimeTableTestId).getByText('Host', { exact: true }), + noFilterResults: this.builders.rowByIndex('1').getByRole('alert').filter({ hasText: 'No data found' }), noQueriesAvailable: this.builders.rowByIndex('1').getByRole('alert', { name: 'No queries available' }), queryTextColumnHeader: this.page .getByTestId(realTimeTableTestId) @@ -95,14 +96,13 @@ export default class RealTimeAnalyticsPage extends BasePage { }; /** - * Selects a value in a multi-select column filter (Database / User columns). + * Types into a text column filter (Database / User columns). Supports a + * comma-separated list of lazy (substring) matches, e.g. 'sbtest, orders'. * Expects the filter row to be open already (see openFilters). */ - filterByColumnOption = async (columnHeader: 'Database' | 'User', optionText: string) => { - await pmmTest.step(`Filter ${columnHeader} column by: ${optionText}`, async () => { - await this.page.getByRole('combobox', { name: `Filter by ${columnHeader}` }).click(); - await this.page.getByRole('option', { name: optionText }).click(); - await this.page.keyboard.press('Escape'); + filterByColumnText = async (columnHeader: 'Database' | 'User', filterText: string) => { + await pmmTest.step(`Filter ${columnHeader} column by: ${filterText}`, async () => { + await this.page.getByTitle(`Filter by ${columnHeader}`).fill(filterText); }); }; diff --git a/e2e_tests/tests/qan/rta/mysql.test.ts b/e2e_tests/tests/qan/rta/mysql.test.ts index e87f99e77..f317c1da7 100644 --- a/e2e_tests/tests/qan/rta/mysql.test.ts +++ b/e2e_tests/tests/qan/rta/mysql.test.ts @@ -175,9 +175,9 @@ pmmTest( ); pmmTest( - 'Verify Database and User multi-select filters narrow the query list @rta', + 'Verify Database and User text filters support comma-separated lazy matching @rta', async ({ page, queryAnalytics }) => { - await pmmTest.step('Mock RTA search response with queries from two databases', async () => { + await pmmTest.step('Mock RTA search response with queries from three databases', async () => { await page.route(apiEndpoints.realtimeanalytics.queriesSearch, (route) => route.fulfill({ body: JSON.stringify({ @@ -194,6 +194,12 @@ pmmTest( queryText: 'SELECT id FROM orders WHERE status=1', username: 'app@localhost', }), + buildMySqlQuery({ + database: 'inventory', + queryId: '203', + queryText: 'SELECT sku FROM inventory WHERE qty=0', + username: 'app@localhost', + }), ], }), contentType: 'application/json', @@ -202,31 +208,33 @@ pmmTest( ); await page.reload(); - await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(2, { + await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(3, { timeout: Timeouts.TEN_SECONDS, }); }); - await pmmTest.step('Filter by database and verify only matching rows remain', async () => { + await pmmTest.step('Filter by a partial database name (lazy match)', async () => { await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); await queryAnalytics.rta.openFilters(); - await queryAnalytics.rta.filterByColumnOption('Database', 'sbtest'); + await queryAnalytics.rta.filterByColumnText('Database', 'sbt'); await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT c FROM sbtest1')).toBeVisible(); - await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT id FROM orders')).toHaveCount(0); + await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(1); }); - await pmmTest.step('Verify User filter options are faceted to the filtered rows', async () => { - await page.getByRole('combobox', { name: 'Filter by User' }).click(); - - // The hidden row's user is no longer offered; only users present in the - // currently filtered result set are. - await expect(page.getByRole('option', { name: 'app@localhost' })).toBeHidden(); - await page.getByRole('option', { name: 'sbtest@localhost' }).click(); - await page.keyboard.press('Escape'); + await pmmTest.step('Filter by a comma-separated database list (any term matches)', async () => { + await queryAnalytics.rta.filterByColumnText('Database', 'sbtest, ord'); await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT c FROM sbtest1')).toBeVisible(); - await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT id FROM orders')).toHaveCount(0); + await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT id FROM orders')).toBeVisible(); + await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT sku FROM inventory')).toHaveCount(0); + }); + + await pmmTest.step('Combine with a User filter that matches none of the remaining rows', async () => { + await queryAnalytics.rta.filterByColumnText('Database', 'orders'); + await queryAnalytics.rta.filterByColumnText('User', 'sbtest@localhost'); + + await expect(queryAnalytics.rta.elements.noFilterResults).toBeVisible(); }); }, ); From bc1072d013f40b7783a51b0735dd025c3266f95e Mon Sep 17 00:00:00 2001 From: theTibi Date: Tue, 4 Aug 2026 22:30:27 +0200 Subject: [PATCH 5/9] PMM-15284 Follow RTA overview default-column changes The RTA overview now hides Database and User by default (percona/pmm#5509), so the QA coverage is updated to match: - cells are addressed by their query---cell test id instead of by column position, which is no longer stable when columns are hidden; - elapsed time is parsed from the compact form ('1.5s', '42s') the overview renders, where splitting on a space returned NaN; - showColumns() reveals Database and User through the Show/Hide columns menu for the tests that assert or filter on them; - new tests cover the hidden-by-default columns and the compact elapsed time format. --- .../pages/qan/rta/realTimeAnalytics.page.ts | 63 ++++++++++----- e2e_tests/tests/qan/rta/mysql.test.ts | 76 +++++++++++++++++++ 2 files changed, 121 insertions(+), 18 deletions(-) diff --git a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts index b39f01a18..f15bd6a37 100644 --- a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts +++ b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts @@ -13,26 +13,35 @@ export default class RealTimeAnalyticsPage extends BasePage { toggles = { hideCommit: this.page.getByTestId('overview-table-hide-commit-toggle'), }; - // Column order: 1 Query text, 2 Host, 3 Database, 4 User, 5 Operation ID, 6 Elapsed time (pinned right). + // Cells are addressed by their `query---cell` test id rather than by + // column position: Database and User are hidden by default and revealed on + // demand (see showColumns), so positions are not stable. builders = { - databaseForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=3]'), + databaseForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).getByTestId(/-database-cell$/), detailsPaneCodeByText: (queryText: string) => this.elements.detailsPane.locator('[data-testid="query-text"], code.language-mongodb', { hasText: queryText, }), elapsedTimeForQueryByText: (queryText: string) => - this.builders.rowByQueryText(queryText).locator('//td[position()=6]'), - elapsedTimeForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=6]'), + this.builders.rowByQueryText(queryText).getByTestId(/-elapsed-time-cell$/), + elapsedTimeForRow: (rowIndex: string) => + this.builders.rowByIndex(rowIndex).getByTestId(/-elapsed-time-cell$/), hostForLastRow: () => - this.page.getByTestId(realTimeTableTestId).locator('tbody tr').last().locator('td').nth(1), - hostForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=2]'), - operationIdForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=5]'), - queryByRowIndex: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=1]'), + this.page + .getByTestId(realTimeTableTestId) + .locator('tbody tr') + .last() + .getByTestId(/-host-cell$/), + hostForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).getByTestId(/-host-cell$/), + operationIdForRow: (rowIndex: string) => + this.builders.rowByIndex(rowIndex).getByTestId(/-operation-id-cell$/), + queryByRowIndex: (rowIndex: string) => + this.builders.rowByIndex(rowIndex).getByTestId(/-query-text-cell$/), rowByIndex: (rowIndex: string) => this.page.getByTestId(realTimeTableTestId).locator(`//tbody//tr[position()=${rowIndex}]`), rowByQueryText: (queryText: string) => this.page.getByTestId(realTimeTableTestId).locator(`tr`, { hasText: queryText }), - userForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).locator('//td[position()=4]'), + userForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).getByTestId(/-user-cell$/), }; buttons = { allSessions: this.page.getByTestId('overview-table-all-sessions-button'), @@ -48,6 +57,7 @@ export default class RealTimeAnalyticsPage extends BasePage { refresh: this.page.getByTestId('overview-table-refresh-button'), refreshIntervalDropdown: this.page.getByTestId('auto-refresh-button'), resumeRealTimeAnalytics: this.page.getByTestId('overview-table-resume-button'), + showHideColumns: this.page.getByRole('button', { name: 'Show/Hide columns' }), stopAgentsButton: this.page.getByTestId('stop-multiple-sessions-modal-stop'), stopAllSessions: this.page.getByTestId('open-stop-all-modal'), }; @@ -98,7 +108,8 @@ export default class RealTimeAnalyticsPage extends BasePage { /** * Types into a text column filter (Database / User columns). Supports a * comma-separated list of lazy (substring) matches, e.g. 'sbtest, orders'. - * Expects the filter row to be open already (see openFilters). + * Expects the column to be revealed already (see showColumns) and the filter + * row to be open already (see openFilters). */ filterByColumnText = async (columnHeader: 'Database' | 'User', filterText: string) => { await pmmTest.step(`Filter ${columnHeader} column by: ${filterText}`, async () => { @@ -137,19 +148,13 @@ export default class RealTimeAnalyticsPage extends BasePage { getElapsedTimeForQueryByRow = async (rowIndex: string) => { await this.builders.elapsedTimeForRow(rowIndex).waitFor({ state: 'visible' }); - const elapsedTime = await this.builders.elapsedTimeForRow(rowIndex).textContent(); - const seconds = elapsedTime?.split(' ')[0]; - - return Number(seconds); + return this.parseElapsedSeconds(await this.builders.elapsedTimeForRow(rowIndex).textContent()); }; getElapsedTimeForQueryByText = async (queryText: string) => { await this.builders.elapsedTimeForQueryByText(queryText).waitFor({ state: 'visible' }); - const elapsedTime = await this.builders.elapsedTimeForQueryByText(queryText).textContent(); - const seconds = elapsedTime?.split(' ')[0]; - - return Number(seconds); + return this.parseElapsedSeconds(await this.builders.elapsedTimeForQueryByText(queryText).textContent()); }; getOperationIdByRow = async (rowIndex: string) => { @@ -187,6 +192,23 @@ export default class RealTimeAnalyticsPage extends BasePage { await this.page.keyboard.press('Escape'); }; + /** + * Reveals columns that the overview hides by default (Database, User) through + * the table's Show/Hide columns menu. + */ + showColumns = async (...columns: ('Database' | 'User')[]) => { + await pmmTest.step(`Show columns: ${columns.join(', ')}`, async () => { + await this.buttons.showHideColumns.click(); + + for (const column of columns) { + await this.page.getByRole('checkbox', { exact: true, name: column }).click(); + } + + await this.page.keyboard.press('Escape'); + await expect(this.page.getByRole('menu')).toBeHidden(); + }); + }; + stopAllSessions = async () => { await this.buttons.stopAllSessions.waitFor({ state: 'visible', timeout: Timeouts.THREE_SECONDS }); await this.buttons.stopAllSessions.click(); @@ -241,4 +263,9 @@ export default class RealTimeAnalyticsPage extends BasePage { }; private apiRequest = (request: Request) => request.url().includes(this.apiEndpoint); + + // Elapsed time is rendered in the compact form the overview uses, e.g. '1.5s' + // below ten seconds and '42s' above it. + private parseElapsedSeconds = (elapsedTime: null | string) => + Number.parseFloat((elapsedTime ?? '').replace(/[^\d.]/g, '')); } diff --git a/e2e_tests/tests/qan/rta/mysql.test.ts b/e2e_tests/tests/qan/rta/mysql.test.ts index f317c1da7..20f0e70f0 100644 --- a/e2e_tests/tests/qan/rta/mysql.test.ts +++ b/e2e_tests/tests/qan/rta/mysql.test.ts @@ -87,6 +87,9 @@ pmmTest( await pmmTest.step('Verify host, database and user columns for the MySQL row', async () => { await expect(queryAnalytics.rta.builders.hostForRow('1')).toHaveText(service.service_name); + + await queryAnalytics.rta.showColumns('Database', 'User'); + await expect(queryAnalytics.rta.builders.databaseForRow('1')).toHaveText('test'); await expect(queryAnalytics.rta.builders.userForRow('1')).toContainText('msandbox'); }); @@ -215,6 +218,7 @@ pmmTest( await pmmTest.step('Filter by a partial database name (lazy match)', async () => { await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); + await queryAnalytics.rta.showColumns('Database', 'User'); await queryAnalytics.rta.openFilters(); await queryAnalytics.rta.filterByColumnText('Database', 'sbt'); @@ -239,6 +243,77 @@ pmmTest( }, ); +pmmTest( + 'Verify Database and User columns are hidden by default and can be revealed @rta', + async ({ page, queryAnalytics }) => { + await pmmTest.step('Mock RTA search response with a MySQL query', async () => { + await page.route(apiEndpoints.realtimeanalytics.queriesSearch, (route) => + route.fulfill({ + body: JSON.stringify({ + queries: [buildMySqlQuery({ queryId: '501', queryText: 'SELECT c FROM sbtest1 WHERE id=21' })], + }), + contentType: 'application/json', + status: 200, + }), + ); + await page.reload(); + + await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(1, { + timeout: Timeouts.TEN_SECONDS, + }); + }); + + await pmmTest.step('Verify only the default columns are shown', async () => { + await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); + + await expect(queryAnalytics.rta.elements.queryTextColumnHeader).toBeVisible(); + await expect(queryAnalytics.rta.elements.hostColumnHeader).toBeVisible(); + await expect(queryAnalytics.rta.elements.elapsedTimeColumnHeader).toBeVisible(); + await expect(queryAnalytics.rta.elements.databaseColumnHeader).toBeHidden(); + await expect(queryAnalytics.rta.elements.userColumnHeader).toBeHidden(); + await expect(queryAnalytics.rta.builders.databaseForRow('1')).toHaveCount(0); + await expect(queryAnalytics.rta.builders.userForRow('1')).toHaveCount(0); + }); + + await pmmTest.step('Reveal Database and User and verify their values', async () => { + await queryAnalytics.rta.showColumns('Database', 'User'); + + await expect(queryAnalytics.rta.elements.databaseColumnHeader).toBeVisible(); + await expect(queryAnalytics.rta.elements.userColumnHeader).toBeVisible(); + await expect(queryAnalytics.rta.builders.databaseForRow('1')).toHaveText('sbtest'); + await expect(queryAnalytics.rta.builders.userForRow('1')).toHaveText('sbtest@localhost'); + }); + }, +); + +pmmTest( + 'Verify elapsed time is rendered in compact seconds format @rta', + async ({ page, queryAnalytics }) => { + await pmmTest.step('Mock RTA search response with a two-second query', async () => { + await page.route(apiEndpoints.realtimeanalytics.queriesSearch, (route) => + route.fulfill({ + body: JSON.stringify({ + queries: [buildMySqlQuery({ queryId: '601', queryText: 'SELECT c FROM sbtest1 WHERE id=34' })], + }), + contentType: 'application/json', + status: 200, + }), + ); + await page.reload(); + + await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(1, { + timeout: Timeouts.TEN_SECONDS, + }); + }); + + await pmmTest.step('Verify the elapsed time cell uses the unit suffix, not the word', async () => { + await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); + + await expect(queryAnalytics.rta.builders.elapsedTimeForRow('1')).toHaveText('2s'); + }); + }, +); + pmmTest( 'Verify overview renders MongoDB and MySQL queries side by side @rta', async ({ page, queryAnalytics }) => { @@ -267,6 +342,7 @@ pmmTest( await pmmTest.step('Verify each row resolves database and user from its own payload', async () => { await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); + await queryAnalytics.rta.showColumns('Database', 'User'); const mongoRow = queryAnalytics.rta.builders.rowByQueryText('find: "mycollection"'); const mySqlRow = queryAnalytics.rta.builders.rowByQueryText('SELECT c FROM sbtest1'); From 8a9c39999c39e50fa10726d5370e41b2cd0787fb Mon Sep 17 00:00:00 2001 From: theTibi Date: Tue, 4 Aug 2026 22:41:09 +0200 Subject: [PATCH 6/9] PMM-15284 Make showColumns enforce column visibility check() leaves an already visible column visible, where click() toggled it back off. No test hits that today - each caller runs in its own test, where the columns start hidden - but the helper is now safe to call more than once and if a column ever becomes visible by default. --- e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts index f15bd6a37..f17b8b291 100644 --- a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts +++ b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts @@ -194,14 +194,15 @@ export default class RealTimeAnalyticsPage extends BasePage { /** * Reveals columns that the overview hides by default (Database, User) through - * the table's Show/Hide columns menu. + * the table's Show/Hide columns menu. Uses check() rather than click() so the + * requested columns end up visible even if some of them already are. */ showColumns = async (...columns: ('Database' | 'User')[]) => { await pmmTest.step(`Show columns: ${columns.join(', ')}`, async () => { await this.buttons.showHideColumns.click(); for (const column of columns) { - await this.page.getByRole('checkbox', { exact: true, name: column }).click(); + await this.page.getByRole('checkbox', { exact: true, name: column }).check(); } await this.page.keyboard.press('Escape'); From 253327976925bfe0f298c91fbd4b9c94c9de1475 Mon Sep 17 00:00:00 2001 From: theTibi Date: Wed, 5 Aug 2026 15:00:08 +0200 Subject: [PATCH 7/9] PMM-15284 Follow RTA elapsed-time precision change The overview keeps three decimals below ten seconds (percona/pmm#5509), so the mocked two-second query now renders as '2.000s'. --- e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts | 4 ++-- e2e_tests/tests/qan/rta/mysql.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts index f17b8b291..106fc0385 100644 --- a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts +++ b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts @@ -265,8 +265,8 @@ export default class RealTimeAnalyticsPage extends BasePage { private apiRequest = (request: Request) => request.url().includes(this.apiEndpoint); - // Elapsed time is rendered in the compact form the overview uses, e.g. '1.5s' - // below ten seconds and '42s' above it. + // Elapsed time is rendered in the compact form the overview uses, e.g. + // '0.003s' below ten seconds and '42s' above it. private parseElapsedSeconds = (elapsedTime: null | string) => Number.parseFloat((elapsedTime ?? '').replace(/[^\d.]/g, '')); } diff --git a/e2e_tests/tests/qan/rta/mysql.test.ts b/e2e_tests/tests/qan/rta/mysql.test.ts index 20f0e70f0..d19eb5751 100644 --- a/e2e_tests/tests/qan/rta/mysql.test.ts +++ b/e2e_tests/tests/qan/rta/mysql.test.ts @@ -309,7 +309,7 @@ pmmTest( await pmmTest.step('Verify the elapsed time cell uses the unit suffix, not the word', async () => { await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); - await expect(queryAnalytics.rta.builders.elapsedTimeForRow('1')).toHaveText('2s'); + await expect(queryAnalytics.rta.builders.elapsedTimeForRow('1')).toHaveText('2.000s'); }); }, ); From 6331426b4982d77e9b11ba76f6d1445e28d579be Mon Sep 17 00:00:00 2001 From: theTibi Date: Wed, 5 Aug 2026 22:19:28 +0200 Subject: [PATCH 8/9] PMM-15284 Cover RTA technology, pinning and toggle changes Follows percona/pmm#5509: the sessions list names the technology when engines are mixed, Elapsed time stays pinned while no column can be pinned by hand, and the row filter is now labelled "Hide transaction control". Adds a mocked two-engine session list so the technology column is covered without needing the environment to monitor MySQL and MongoDB at once, and gates the pin-control assertion on the Show/Hide columns menu actually opening. --- e2e_tests/helpers/apiEndpoints.ts | 1 + .../pages/qan/rta/realTimeAnalytics.page.ts | 13 +++ e2e_tests/tests/qan/rta/mysql.test.ts | 98 +++++++++++++++++++ 3 files changed, 112 insertions(+) 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/pages/qan/rta/realTimeAnalytics.page.ts b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts index 106fc0385..f4d07d3c7 100644 --- a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts +++ b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts @@ -5,10 +5,12 @@ import apiEndpoints from '@helpers/apiEndpoints'; import { Timeouts } from '@helpers/timeouts'; const realTimeTableTestId = 'realtime-overview-table'; +const sessionsTableTestId = 'rta-sessions'; export default class RealTimeAnalyticsPage extends BasePage { readonly url = 'pmm-ui/rta/overview'; readonly refreshIntervals = ['1s', '2s', '3s', '4s', '5s'] as const; + readonly sessionsUrl = 'pmm-ui/rta/sessions'; apiEndpoint = apiEndpoints.realtimeanalytics.queriesSearch; toggles = { hideCommit: this.page.getByTestId('overview-table-hide-commit-toggle'), @@ -41,6 +43,11 @@ 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 }), + technologyForSession: (sessionName: string) => + this.page + .getByTestId(sessionsTableTestId) + .locator('tr', { hasText: sessionName }) + .getByTestId('technology'), userForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).getByTestId(/-user-cell$/), }; buttons = { @@ -62,6 +69,8 @@ export default class RealTimeAnalyticsPage extends BasePage { stopAllSessions: this.page.getByTestId('open-stop-all-modal'), }; elements = { + columnPinButtons: this.page.getByRole('button', { name: /^(Pin to (left|right)|Unpin)$/ }), + columnsMenu: this.page.getByRole('menu'), databaseColumnHeader: this.page.getByTestId(realTimeTableTestId).getByText('Database', { exact: true }), detailsCommand: this.page.getByTestId('command-value'), detailsFullScan: this.page.getByTestId('full-scan-value'), @@ -84,6 +93,10 @@ export default class RealTimeAnalyticsPage extends BasePage { .getByText('Query text', { exact: true }), realTimeTable: this.page.getByTestId(realTimeTableTestId), realTimeTableRow: this.page.getByTestId(realTimeTableTestId).locator('tbody tr'), + sessionsTable: this.page.getByTestId(sessionsTableTestId), + technologyColumnHeader: this.page + .getByTestId(sessionsTableTestId) + .getByText('Technology', { exact: true }), userColumnHeader: this.page.getByTestId(realTimeTableTestId).getByText('User', { exact: true }), }; inputs = { diff --git a/e2e_tests/tests/qan/rta/mysql.test.ts b/e2e_tests/tests/qan/rta/mysql.test.ts index d19eb5751..ccdbd166a 100644 --- a/e2e_tests/tests/qan/rta/mysql.test.ts +++ b/e2e_tests/tests/qan/rta/mysql.test.ts @@ -163,6 +163,8 @@ pmmTest( }); await pmmTest.step('Enable Hide COMMIT and verify the COMMIT row disappears', async () => { + await expect(queryAnalytics.rta.toggles.hideCommit).toHaveText('Hide transaction control'); + await queryAnalytics.rta.toggleHideCommit(); await expect(queryAnalytics.rta.builders.rowByQueryText('COMMIT')).toHaveCount(0); @@ -286,6 +288,45 @@ pmmTest( }, ); +pmmTest( + 'Verify Elapsed time stays pinned and no column can be pinned by hand @rta', + async ({ page, queryAnalytics }) => { + await pmmTest.step('Mock RTA search response with a MySQL query', async () => { + await page.route(apiEndpoints.realtimeanalytics.queriesSearch, (route) => + route.fulfill({ + body: JSON.stringify({ + queries: [buildMySqlQuery({ queryId: '701', queryText: 'SELECT c FROM sbtest1 WHERE id=55' })], + }), + contentType: 'application/json', + status: 200, + }), + ); + await page.reload(); + + await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(1, { + timeout: Timeouts.TEN_SECONDS, + }); + }); + + await pmmTest.step('Verify the elapsed time cell is still pinned', async () => { + await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); + + await expect(queryAnalytics.rta.builders.elapsedTimeForRow('1')).toHaveAttribute('data-pinned', 'true'); + }); + + await pmmTest.step('Verify the Show/Hide columns menu offers no pin controls', async () => { + await queryAnalytics.rta.buttons.showHideColumns.click(); + + // Wait for the menu itself first: counting pin buttons on a page where the + // menu never opened would pass while asserting nothing. + await expect(queryAnalytics.rta.elements.columnsMenu).toBeVisible(); + await expect(queryAnalytics.rta.elements.columnPinButtons).toHaveCount(0); + + await page.keyboard.press('Escape'); + }); + }, +); + pmmTest( 'Verify elapsed time is rendered in compact seconds format @rta', async ({ page, queryAnalytics }) => { @@ -372,6 +413,63 @@ pmmTest( }, ); +// The session list only names the technology when the running sessions span more +// than one engine, so both are mocked here rather than relying on the +// environment to monitor a MySQL and a MongoDB service at once. +const buildSession = (overrides: { serviceId: string; serviceName: string; serviceType: string }) => ({ + cluster_name: '', + collect_interval: '2s', + service_id: overrides.serviceId, + service_name: overrides.serviceName, + service_type: overrides.serviceType, + start_time: new Date().toISOString(), + status: 'SESSION_STATUS_RUNNING', +}); + +pmmTest( + 'Verify the sessions list names the technology when engines are mixed @rta', + async ({ page, queryAnalytics }) => { + await pmmTest.step('Mock one MySQL and one MongoDB session', async () => { + await page.route(apiEndpoints.realtimeanalytics.sessions, (route) => + route.fulfill({ + body: JSON.stringify({ + sessions: [ + buildSession({ + serviceId: 'mock-mysql-service', + serviceName: 'mock-mysql-service', + serviceType: 'SERVICE_TYPE_MYSQL_SERVICE', + }), + buildSession({ + serviceId: 'mock-mongo-service', + serviceName: 'mock-mongo-service', + serviceType: 'SERVICE_TYPE_MONGODB_SERVICE', + }), + ], + }), + contentType: 'application/json', + status: 200, + }), + ); + + await page.goto(queryAnalytics.rta.sessionsUrl); + + await expect(queryAnalytics.rta.elements.sessionsTable).toBeVisible({ + timeout: Timeouts.TEN_SECONDS, + }); + }); + + await pmmTest.step('Verify each session row names its technology', async () => { + await expect(queryAnalytics.rta.elements.technologyColumnHeader).toBeVisible(); + await expect(queryAnalytics.rta.builders.technologyForSession('mock-mysql-service')).toHaveText( + 'MySQL', + ); + await expect(queryAnalytics.rta.builders.technologyForSession('mock-mongo-service')).toHaveText( + 'MongoDB', + ); + }); + }, +); + pmmTest( 'Verify RTA CSV export contains MySQL-specific columns @rta', async ({ page, queryAnalytics }, testInfo) => { From 9e13a1353e980b80e10ce42aa722cddbe6b48909 Mon Sep 17 00:00:00 2001 From: theTibi Date: Fri, 7 Aug 2026 10:56:23 +0200 Subject: [PATCH 9/9] PMM-15284 Follow the one-technology-per-view change The overview shows one technology at a time now (percona/pmm#5509), so the side-by-side rendering test no longer describes the product. It is replaced by one asserting the picker groups services by technology and disables the other one, plus a MongoDB test covering that Database, User and the transaction-control toggle are not offered there. The session list names the technology of every session, not only when engines are mixed, so that test loses its precondition. --- e2e_tests/node_modules | 1 + .../pages/qan/rta/realTimeAnalytics.page.ts | 11 ++ e2e_tests/tests/qan/rta/mysql.test.ts | 120 ++++++++++++------ 3 files changed, 95 insertions(+), 37 deletions(-) create mode 120000 e2e_tests/node_modules diff --git a/e2e_tests/node_modules b/e2e_tests/node_modules new file mode 120000 index 000000000..a4f796e76 --- /dev/null +++ b/e2e_tests/node_modules @@ -0,0 +1 @@ +/Users/tibi/workspace/percona/local_git_forks/pmm-qa/e2e_tests/node_modules \ No newline at end of file diff --git a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts index f4d07d3c7..02dffc133 100644 --- a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts +++ b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts @@ -19,6 +19,8 @@ export default class RealTimeAnalyticsPage extends BasePage { // column position: Database and User are hidden by default and revealed on // demand (see showColumns), so positions are not stable. builders = { + columnToggle: (columnHeader: string) => + this.page.getByRole('checkbox', { exact: true, name: columnHeader }), databaseForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).getByTestId(/-database-cell$/), detailsPaneCodeByText: (queryText: string) => this.elements.detailsPane.locator('[data-testid="query-text"], code.language-mongodb', { @@ -43,11 +45,14 @@ 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 }), + serviceOption: (serviceId: string) => this.page.getByTestId(`service-option-${serviceId}`), technologyForSession: (sessionName: string) => this.page .getByTestId(sessionsTableTestId) .locator('tr', { hasText: sessionName }) .getByTestId('technology'), + technologyGroupHeader: (technology: string) => + this.page.getByRole('listbox').locator('.MuiAutocomplete-groupLabel', { hasText: technology }), userForRow: (rowIndex: string) => this.builders.rowByIndex(rowIndex).getByTestId(/-user-cell$/), }; buttons = { @@ -199,6 +204,12 @@ export default class RealTimeAnalyticsPage extends BasePage { await this.buttons.filters.click(); }; + // Opens the Cluster/Service picker of the overview. + openServicesDropdown = async () => { + await this.page.getByTitle('Open').click(); + await expect(this.page.getByRole('listbox')).toBeVisible(); + }; + selectClusterService = async () => { await this.inputs.clusterService.click(); await this.page.getByRole('option').first().click(); diff --git a/e2e_tests/tests/qan/rta/mysql.test.ts b/e2e_tests/tests/qan/rta/mysql.test.ts index ccdbd166a..61e51c40e 100644 --- a/e2e_tests/tests/qan/rta/mysql.test.ts +++ b/e2e_tests/tests/qan/rta/mysql.test.ts @@ -355,67 +355,113 @@ pmmTest( }, ); +pmmTest('Verify the overview picker refuses to mix technologies @rta', async ({ page, queryAnalytics }) => { + await pmmTest.step('Mock one MySQL and one MongoDB session', async () => { + await page.route(apiEndpoints.realtimeanalytics.sessions, (route) => + route.fulfill({ + body: JSON.stringify({ + sessions: [ + buildSession({ + serviceId: 'mock-mysql-service', + serviceName: 'mock-mysql-service', + serviceType: 'SERVICE_TYPE_MYSQL_SERVICE', + }), + buildSession({ + serviceId: 'mock-mongo-service', + serviceName: 'mock-mongo-service', + serviceType: 'SERVICE_TYPE_MONGODB_SERVICE', + }), + ], + }), + contentType: 'application/json', + status: 200, + }), + ); + + await page.goto(queryAnalytics.rta.getUrlWithServices(['mock-mysql-service'])); + + await expect(queryAnalytics.rta.elements.realTimeTable).toBeVisible({ + timeout: Timeouts.TEN_SECONDS, + }); + }); + + await pmmTest.step('Verify the other technology is grouped and not selectable', async () => { + await queryAnalytics.rta.openServicesDropdown(); + + // A view of live queries shows one technology at a time, so picking a + // MySQL service leaves the MongoDB ones disabled. + await expect(queryAnalytics.rta.builders.serviceOption('mock-mysql-service')).not.toHaveAttribute( + 'aria-disabled', + 'true', + ); + await expect(queryAnalytics.rta.builders.serviceOption('mock-mongo-service')).toHaveAttribute( + 'aria-disabled', + 'true', + ); + await expect(queryAnalytics.rta.builders.technologyGroupHeader('MySQL')).toBeVisible(); + await expect(queryAnalytics.rta.builders.technologyGroupHeader('MongoDB')).toBeVisible(); + }); +}); + pmmTest( - 'Verify overview renders MongoDB and MySQL queries side by side @rta', + 'Verify Database and User columns are not offered for MongoDB @rta', async ({ page, queryAnalytics }) => { - const mongoQueryText = '{ find: "mycollection", filter: { status: "active" } }'; - const mySqlQueryText = 'SELECT c FROM sbtest1 WHERE id=7'; - - await pmmTest.step('Mock RTA search response with one query per engine', async () => { + await pmmTest.step('Watch a MongoDB service', async () => { + await page.route(apiEndpoints.realtimeanalytics.sessions, (route) => + route.fulfill({ + body: JSON.stringify({ + sessions: [ + buildSession({ + serviceId: 'mock-mongo-service', + serviceName: 'mock-mongo-service', + serviceType: 'SERVICE_TYPE_MONGODB_SERVICE', + }), + ], + }), + contentType: 'application/json', + status: 200, + }), + ); await page.route(apiEndpoints.realtimeanalytics.queriesSearch, (route) => route.fulfill({ body: JSON.stringify({ queries: [ - buildMongoDbQuery({ queryId: '301', queryText: mongoQueryText }), - buildMySqlQuery({ queryId: '302', queryText: mySqlQueryText }), + buildMongoDbQuery({ + queryId: '801', + queryText: '{ find: "mycollection", filter: { status: "active" } }', + }), ], }), contentType: 'application/json', status: 200, }), ); - await page.reload(); - await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(2, { + await page.goto(queryAnalytics.rta.getUrlWithServices(['mock-mongo-service'])); + + await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(1, { timeout: Timeouts.TEN_SECONDS, }); }); - await pmmTest.step('Verify each row resolves database and user from its own payload', async () => { + await pmmTest.step('Verify neither column can be revealed, and no COMMIT toggle', async () => { await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); - await queryAnalytics.rta.showColumns('Database', 'User'); - - const mongoRow = queryAnalytics.rta.builders.rowByQueryText('find: "mycollection"'); - const mySqlRow = queryAnalytics.rta.builders.rowByQueryText('SELECT c FROM sbtest1'); - - await expect(mongoRow).toContainText('admin'); - await expect(mongoRow).toContainText('pmm-mongo'); - await expect(mySqlRow).toContainText('sbtest'); - await expect(mySqlRow).toContainText('sbtest@localhost'); - }); - - await pmmTest.step('Verify details pane switches payload-specific fields per engine', async () => { - await queryAnalytics.rta.builders.rowByQueryText('find: "mycollection"').click(); - await expect(queryAnalytics.rta.elements.detailsPane).toBeVisible(); - - // MongoDB-only fields visible, MySQL-only fields absent. - await expect(queryAnalytics.rta.elements.detailsPane.getByTestId('collection-value')).toBeVisible(); - await expect(queryAnalytics.rta.elements.detailsCommand).toBeHidden(); + await queryAnalytics.rta.buttons.showHideColumns.click(); - await queryAnalytics.rta.buttons.detailsNextQuery.click(); + await expect(queryAnalytics.rta.elements.columnsMenu).toBeVisible(); + await expect(queryAnalytics.rta.builders.columnToggle('Database')).toHaveCount(0); + await expect(queryAnalytics.rta.builders.columnToggle('User')).toHaveCount(0); - // And the other way around for the MySQL row. - await expect(queryAnalytics.rta.elements.detailsCommand).toBeVisible(); - await expect(queryAnalytics.rta.elements.detailsPane.getByTestId('collection-value')).toBeHidden(); + await page.keyboard.press('Escape'); - await queryAnalytics.rta.buttons.closeDetailsPane.click(); + // Transaction-control statements are a MySQL concern. + await expect(queryAnalytics.rta.toggles.hideCommit).toHaveCount(0); }); }, ); -// The session list only names the technology when the running sessions span more -// than one engine, so both are mocked here rather than relying on the -// environment to monitor a MySQL and a MongoDB service at once. +// Sessions are mocked rather than relying on the environment to monitor a MySQL +// and a MongoDB service at once. const buildSession = (overrides: { serviceId: string; serviceName: string; serviceType: string }) => ({ cluster_name: '', collect_interval: '2s', @@ -427,7 +473,7 @@ const buildSession = (overrides: { serviceId: string; serviceName: string; servi }); pmmTest( - 'Verify the sessions list names the technology when engines are mixed @rta', + 'Verify the sessions list names the technology of every session @rta', async ({ page, queryAnalytics }) => { await pmmTest.step('Mock one MySQL and one MongoDB session', async () => { await page.route(apiEndpoints.realtimeanalytics.sessions, (route) =>