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..2f83f0d24 100644 --- a/cli/tests/perconaMySqlServer.spec.ts +++ b/cli/tests/perconaMySqlServer.spec.ts @@ -230,5 +230,56 @@ 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.assertSuccess(); + 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.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'); + }); + + 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/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/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/mysql.helper.ts b/e2e_tests/helpers/mysql.helper.ts new file mode 100644 index 000000000..9ee928da9 --- /dev/null +++ b/e2e_tests/helpers/mysql.helper.ts @@ -0,0 +1,95 @@ +import mysql, { Connection, 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 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.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 () => { + 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); + + /** + * 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 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 + * 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 or the helper is closed + */ + simulateLongRunningQuery = async ( + options: { + delayMs?: number; + queryLabel?: string; + } = {}, + ) => { + const { delayMs = Timeouts.TEN_SECONDS, queryLabel = 'rta-simulated-query' } = options; + const delaySeconds = Math.max(1, Math.ceil(delayMs / 1_000)); + let connection: Connection | undefined; + + try { + 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(); + } + } + }; +} 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/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..02dffc133 100644 --- a/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts +++ b/e2e_tests/pages/qan/rta/realTimeAnalytics.page.ts @@ -5,34 +5,62 @@ 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'), + }; + // 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 = { + 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', { 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).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()=3]'), - 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 }), + 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 = { 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' }), @@ -41,22 +69,40 @@ 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'), }; 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'), 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 }), 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) .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 = { clusterService: this.page.locator('input[name = "service"]'), @@ -77,6 +123,18 @@ export default class RealTimeAnalyticsPage extends BasePage { await this.elements.queryTextColumnHeader.click(); }; + /** + * Types into a text column filter (Database / User columns). Supports a + * comma-separated list of lazy (substring) matches, e.g. 'sbtest, orders'. + * 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 () => { + await this.page.getByTitle(`Filter by ${columnHeader}`).fill(filterText); + }); + }; + filterQueriesByText = async (queryText: string) => { await pmmTest.step(`Filter queries by text: ${queryText}`, async () => { await this.builders.rowByIndex('1').waitFor({ state: 'visible' }); @@ -108,19 +166,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) => { @@ -152,12 +204,36 @@ 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(); await this.page.keyboard.press('Escape'); }; + /** + * Reveals columns that the overview hides by default (Database, User) through + * 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 }).check(); + } + + 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(); @@ -165,6 +241,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, @@ -206,4 +288,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. + // '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 new file mode 100644 index 000000000..61e51c40e --- /dev/null +++ b/e2e_tests/tests/qan/rta/mysql.test.ts @@ -0,0 +1,565 @@ +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', + state: 'executing', + 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 queryAnalytics.rta.showColumns('Database', 'User'); + + 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 expect(queryAnalytics.rta.toggles.hideCommit).toHaveText('Hide transaction control'); + + 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 text filters support comma-separated lazy matching @rta', + async ({ page, queryAnalytics }) => { + 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({ + 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', + }), + buildMySqlQuery({ + database: 'inventory', + queryId: '203', + queryText: 'SELECT sku FROM inventory WHERE qty=0', + username: 'app@localhost', + }), + ], + }), + contentType: 'application/json', + status: 200, + }), + ); + await page.reload(); + + await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(3, { + timeout: Timeouts.TEN_SECONDS, + }); + }); + + 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'); + + await expect(queryAnalytics.rta.builders.rowByQueryText('SELECT c FROM sbtest1')).toBeVisible(); + await expect(queryAnalytics.rta.elements.realTimeTableRow).toHaveCount(1); + }); + + 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')).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(); + }); + }, +); + +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 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 }) => { + 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('2.000s'); + }); + }, +); + +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 Database and User columns are not offered for MongoDB @rta', + async ({ page, queryAnalytics }) => { + 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: '801', + queryText: '{ find: "mycollection", filter: { status: "active" } }', + }), + ], + }), + contentType: 'application/json', + status: 200, + }), + ); + + 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 neither column can be revealed, and no COMMIT toggle', async () => { + await queryAnalytics.rta.buttons.pauseRealTimeAnalytics.click(); + await queryAnalytics.rta.buttons.showHideColumns.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); + + await page.keyboard.press('Escape'); + + // Transaction-control statements are a MySQL concern. + await expect(queryAnalytics.rta.toggles.hideCommit).toHaveCount(0); + }); + }, +); + +// 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', + 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 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) => + 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) => { + 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);