diff --git a/.github/workflows/e2e-tests-matrix.yml b/.github/workflows/e2e-tests-matrix.yml index e10ac61b7..cdb43ecfd 100644 --- a/.github/workflows/e2e-tests-matrix.yml +++ b/.github/workflows/e2e-tests-matrix.yml @@ -107,16 +107,15 @@ jobs: experimental: name: Experimental UI tests - uses: ./.github/workflows/runner-e2e-tests-codeceptjs.yml - secrets: inherit + uses: ./.github/workflows/runner-e2e-tests-playwright.yml + secrets: + LAUNCHABLE_TOKEN: ${{ secrets.LAUNCHABLE_TOKEN }} with: - pmm_server_image: ${{ inputs.pmm_server_image || github.event.inputs.pmm_server_image || 'perconalab/pmm-server:3-dev-latest' }} + pmm_server_version: ${{ inputs.pmm_server_image || github.event.inputs.pmm_server_image || 'perconalab/pmm-server:3-dev-latest' }} pmm_client_version: ${{ inputs.pmm_client_version || github.event.inputs.pmm_client_version || 'latest-tarball' }} - pmm_client_image: ${{ inputs.pmm_client_image || github.event.inputs.pmm_client_image || 'perconalab/pmm-client:3-dev-latest' }} pmm_qa_branch: ${{ github.event_name == 'pull_request' && github.head_ref || (inputs.pmm_qa_branch || github.event.inputs.pmm_qa_branch || 'main') }} - sha: ${{ inputs.sha || github.event.pull_request.head.sha || github.event.inputs.sha || 'null' }} setup_services: '--database pdpgsql' - tags_for_tests: '@experimental' + pmm_test_flag: '@experimental' disconnect: name: Disconnect from PMM Server diff --git a/e2e_tests/components/dashboards/panels/barGauge.component.ts b/e2e_tests/components/dashboards/panels/barGauge.component.ts index 3a153df08..45db74df8 100644 --- a/e2e_tests/components/dashboards/panels/barGauge.component.ts +++ b/e2e_tests/components/dashboards/panels/barGauge.component.ts @@ -1,11 +1,15 @@ import PanelComponent from '@components/dashboards/panels/panel.component'; export default class BarGaugePanel extends PanelComponent { - private elements = { + elements = { barGaugeValues: (panelName: string) => this.grafanaIframe().locator( `//section[@data-testid="data-testid Panel header ${panelName}"]//div[contains(@data-testid, "Bar gauge value")]`, ), + barWithValue: (panelName: string) => + this.grafanaIframe().locator( + `//section[@data-testid="data-testid Panel header ${panelName}"]//div[@data-testid="data-testid Bar gauge value"]//span[text() > "0"]`, + ), }; verifyPanelData = async (panelName: string) => { diff --git a/e2e_tests/fixtures/pmmTest.ts b/e2e_tests/fixtures/pmmTest.ts index b005d6669..4589453bc 100644 --- a/e2e_tests/fixtures/pmmTest.ts +++ b/e2e_tests/fixtures/pmmTest.ts @@ -18,35 +18,9 @@ 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 VacuumDashboard from '@pages/dashboards/postgresql/vacuumDashboard'; import apiEndpoints from '@helpers/apiEndpoints'; -base.beforeEach(async ({ page }) => { - // Mock user details call to prevent the tours from showing - await page.route(apiEndpoints.users.me, (route) => - route.fulfill({ - body: JSON.stringify({ - alerting_tour_completed: true, - product_tour_completed: true, - snoozed_pmm_version: '', - user_id: 1, - }), - status: 200, - }), - ); - // Mock upgrade call to prevent upgrade modal from showing. - await page.route(apiEndpoints.server.updates, (route) => - route.fulfill({ - body: JSON.stringify({ - installed: {}, - last_check: new Date().toISOString(), - latest: {}, - update_available: false, - }), - status: 200, - }), - ); -}); - const pmmTest = base.extend<{ agentsPage: AgentsPage; cliHelper: CliHelper; @@ -67,6 +41,7 @@ const pmmTest = base.extend<{ queryAnalytics: QueryAnalytics; nodesPage: NodesPage; realTimeAnalyticsPage: RealTimeAnalyticsPage; + vacuumDashboardPage: VacuumDashboard; }>({ agentsPage: async ({ page }, use) => await use(new AgentsPage(page)), api: async ({ page, request }, use) => { @@ -80,7 +55,7 @@ const pmmTest = base.extend<{ await use(cliHelper); }, context: async ({ context }, use) => { - await context.route('**/v1/users/me', (route) => + await context.route(apiEndpoints.users.me, (route) => route.fulfill({ body: JSON.stringify({ alerting_tour_completed: true, @@ -92,8 +67,8 @@ const pmmTest = base.extend<{ status: 200, }), ); - await context.route('**/v1/server/updates**', (route) => { - return route.fulfill({ + await context.route(apiEndpoints.server.updates, (route) => + route.fulfill({ body: JSON.stringify({ installed: {}, last_check: new Date().toISOString(), @@ -102,8 +77,8 @@ const pmmTest = base.extend<{ }), contentType: 'application/json', status: 200, - }); - }); + }), + ); await use(context); }, credentials: async ({}, use) => { @@ -179,6 +154,7 @@ const pmmTest = base.extend<{ await use(urlHelper); }, + vacuumDashboardPage: async ({ page }, use) => await use(new VacuumDashboard(page)), }); export default pmmTest; diff --git a/e2e_tests/pages/dashboards/postgresql/vacuumDashboard.ts b/e2e_tests/pages/dashboards/postgresql/vacuumDashboard.ts new file mode 100644 index 000000000..206d3b971 --- /dev/null +++ b/e2e_tests/pages/dashboards/postgresql/vacuumDashboard.ts @@ -0,0 +1,83 @@ +import { expect, Locator } from '@playwright/test'; +import CliHelper from '@helpers/cli.helper'; +import { Timeouts } from '@helpers/timeouts'; +import pmmTest from '@fixtures/pmmTest'; +import DashboardInterface from '@interfaces/dashboard'; +import BasePage from '@pages/base.page'; + +export default class VacuumDashboard extends BasePage implements DashboardInterface { + readonly url = 'graph/d/postgres_vacuum_monitoring/postgresql-vacuum-monitoring'; + metrics = []; + noDataMetrics = []; + builders = {}; + buttons = {}; + elements = { + lastAnalyzeValue: this.grafanaIframe().locator( + '//div[contains(@class, "react-grid-item")][6]//div[contains(text(), "dvdrental")]//following-sibling::*', + ), + lastVacuumValue: this.grafanaIframe().locator( + '//div[contains(@class, "react-grid-item")][5]//div[contains(text(), "dvdrental")]//following-sibling::*', + ), + }; + inputs = {}; + messages = {}; + + vacuumAnalyzeTables = async (tables: string[], containerName: string) => { + const allowedPrefixes = [ + 'film', + 'actor', + 'store', + 'address', + 'category', + 'city', + 'country', + 'customer', + 'inventory', + 'language', + 'rental', + 'staff', + 'payment', + ]; + const cliHelper = new CliHelper(); + + for (const rawTable of tables) { + const table = rawTable.trim(); + + if (!table) continue; + if (!allowedPrefixes.some((prefix) => table.includes(prefix))) continue; + + await pmmTest.step(`Run VACUUM (ANALYZE) on ${table}`, async () => { + cliHelper + .execSilent( + `docker exec ${containerName} psql -U postgres -d dvdrental -c 'VACUUM (ANALYZE) ${table}'`, + ) + .assertSuccess(); + }); + } + }; + + waitForLastAnalyzeValues = async (timeout: Timeouts = Timeouts.TEN_MINUTES) => { + await this.waitForValidDate(this.elements.lastAnalyzeValue, 'Last Analyze', timeout); + }; + + waitForLastVacuumValues = async (timeout: Timeouts = Timeouts.TEN_MINUTES) => { + await this.waitForValidDate(this.elements.lastVacuumValue, 'Last Vacuum', timeout); + }; + + private waitForValidDate = async (locator: Locator, label: string, timeout: number) => { + const pollInterval = Timeouts.FIVE_SECONDS; + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const values = await locator.allTextContents(); + const hasValidDate = values.some((value) => !Number.isNaN(new Date(value).valueOf())); + + if (hasValidDate) return; + + // eslint-disable-next-line playwright/no-wait-for-timeout -- polling the dashboard for async metric updates + await this.page.waitForTimeout(pollInterval); + } + + expect(false, `No valid ${label} date was rendered within ${timeout / Timeouts.ONE_SECOND}s`).toBe(true); + }; +} diff --git a/e2e_tests/testdata/vacuumDashboardSetup.sql b/e2e_tests/testdata/vacuumDashboardSetup.sql new file mode 100644 index 000000000..25e461f18 --- /dev/null +++ b/e2e_tests/testdata/vacuumDashboardSetup.sql @@ -0,0 +1,23 @@ +-- Creates the `dvdrental` database and 10 film_testing_* tables seeded with +-- 5,000 rows each. Length values are spread across 100..220 so the churn +-- loop in the test can pick a random length and find rows to delete/update. +CREATE DATABASE dvdrental; +\connect dvdrental + +DO $$ +BEGIN + FOR i IN 1..10 LOOP + EXECUTE format( + 'CREATE TABLE film_testing_%s (id int, title text, description text, length int)', + i + ); + EXECUTE format( + $sql$ + INSERT INTO film_testing_%s (id, title, description, length) + SELECT g, 'title for ' || g, 'Description for ' || g, 100 + (g %% 121) + FROM generate_series(1, 5000) AS g + $sql$, + i + ); + END LOOP; +END $$; diff --git a/e2e_tests/tests/dashboards/postgresql/vacuumDashboard.test.ts b/e2e_tests/tests/dashboards/postgresql/vacuumDashboard.test.ts new file mode 100644 index 000000000..e65eff8f5 --- /dev/null +++ b/e2e_tests/tests/dashboards/postgresql/vacuumDashboard.test.ts @@ -0,0 +1,78 @@ +import { expect } from '@playwright/test'; +import pmmTest from '@fixtures/pmmTest'; +import { Timeouts } from '@helpers/timeouts'; + +const SETUP_SQL = 'testdata/vacuumDashboardSetup.sql'; +const randomInRange = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; +let pgsqlContainerName: string; + +pmmTest.beforeAll(async ({ cliHelper }) => { + pgsqlContainerName = cliHelper + .execSilent('docker ps -f name=pgsql --format "{{ .Names }}"') + .stdout.trim() + .split(/\r?\n/)[0]; + + if (!pgsqlContainerName) throw new Error('No running container matching "pgsql" was found.'); + + cliHelper + .execSilent(`docker exec -i ${pgsqlContainerName} psql -U postgres -v ON_ERROR_STOP=1 < ${SETUP_SQL}`) + .assertSuccess(); +}); + +pmmTest.beforeEach(async ({ grafanaHelper }) => { + await grafanaHelper.authorize(); +}); + +pmmTest( + 'PMM-T1365 - Verify PostgreSQL Vacuum monitoring dashboard @dashboards @experimental @pmm-pgsql-integration', + async ({ cliHelper, dashboard, page, urlHelper, vacuumDashboardPage }) => { + const MIN_LENGTH = 100; + const MAX_LENGTH = 220; + const ITERATIONS = 3; + + + await pmmTest.step('Churn rows to produce vacuum/analyze activity', async () => { + for (let i = 0; i < ITERATIONS; i++) { + const table = randomInRange(1, 10); + const oldLength = randomInRange(MIN_LENGTH, MAX_LENGTH); + const newLength = randomInRange(MIN_LENGTH, MAX_LENGTH); + const churnSql = [ + `DELETE FROM film_testing_${table} WHERE length = ${oldLength};`, + `INSERT INTO film_testing_${table} (id, title, description, length)`, + ` SELECT g, 'title for ' || g, 'Description for ' || g, ${oldLength}`, + ` FROM generate_series(1, 50) g;`, + `UPDATE film_testing_${table} SET length = ${newLength} WHERE length = ${oldLength};`, + ].join(' '); + + cliHelper + .execSilent(`docker exec ${pgsqlContainerName} psql -U postgres -d dvdrental -c "${churnSql}"`) + .assertSuccess(); + + // eslint-disable-next-line playwright/no-wait-for-timeout -- give PMM time to scrape updated stats + await page.waitForTimeout(Timeouts.FIVE_SECONDS); + } + }); + + await page.goto( + urlHelper.buildUrlWithParameters(vacuumDashboardPage.url, { + from: 'now-5m', + refresh: '10s', + serviceName: pgsqlContainerName, + }), + ); + + await expect(dashboard.panels().barGauge.elements.barWithValue('Dead Tuples').first()).toBeVisible({ + timeout: Timeouts.FIVE_MINUTES, + }); + + const allTables = cliHelper + .execSilent( + `docker exec ${pgsqlContainerName} psql -U postgres -d dvdrental -t -A -c "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'public'"`, + ) + .stdout.split(/\r?\n/); + + await vacuumDashboardPage.vacuumAnalyzeTables(allTables, pgsqlContainerName); + await vacuumDashboardPage.waitForLastVacuumValues(Timeouts.TEN_MINUTES); + await vacuumDashboardPage.waitForLastAnalyzeValues(Timeouts.TEN_MINUTES); + }, +);