// databaseMonitoringFlows.spec.js // Positive and negative flow coverage for Database Monitoring (Infra -> Databases). // // Companion to databaseMonitoring.spec.js, which pins the specific defects we // hit by hand (route move, badge/table disagreement, the instance filter). // THIS file sweeps the section systematically: every tab, every empty state, // search, pagination, deep links, and the malformed-input cases. // // THE RULE EVERY NEGATIVE TEST HERE FOLLOWS // ----------------------------------------- // "No data" is a legitimate answer on every one of these tabs — a database // with no deadlocks SHOULD show zero deadlocks. So a negative test may never // assert emptiness alone; it asserts the page SAYS WHY it is empty (a declared // empty state) and stays usable (tab strip alive, no crash). An empty frame // with no explanation is the actual defect, and it is invisible to a test that // only counts rows. // // WHY SO FEW HARD ROW-COUNT ASSERTIONS // ------------------------------------ // The rig ingests continuously, so counts move between the act and the assert. // Tests therefore assert INVARIANTS that hold at any volume (filtered <= // unfiltered, page 2 differs from page 1, a nonsense filter cannot out-match an // empty one) rather than pinned numbers, which would fail on a quiet rig and // pass vacuously on a busy one. const { test, expect } = require('../utils/enhanced-baseFixtures.js'); const testLogger = require('../utils/test-logger.js'); const PageManager = require('../../pages/page-manager.js'); /** Escape all regex metacharacters (incl. backslash) so a literal string is safe inside RegExp. */ function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** Every tab in the strip, with the route segment its URL uses. */ const ALL_TABS = [ { tab: 'overview', segment: '' }, { tab: 'queries', segment: 'queries' }, { tab: 'samples', segment: 'samples' }, { tab: 'activity', segment: 'activity' }, { tab: 'deadlocks', segment: 'deadlocks' }, { tab: 'blocked', segment: 'blocking' }, { tab: 'tableHealth', segment: 'table-health' }, ]; /** Tabs with a search box this suite drives. */ const SEARCHABLE_TABS = ['tableHealth', 'activity', 'deadlocks']; test.describe('Database Monitoring — flows', () => { test.describe.configure({ mode: 'parallel' }); let pm; let dbm; /** * The engine THIS org actually holds, resolved once per test from the data. * * Never hardcoded: a suite pinned to `postgresql` sends a system filter that * cannot match a MySQL org, so every scoped assertion below would fail * against a healthy page and look like a product defect. */ let engine = 'postgresql'; test.beforeEach(async ({ page }, testInfo) => { testLogger.testStart(testInfo.title, testInfo.file); pm = new PageManager(page); dbm = pm.databaseMonitoringPage; engine = (await dbm.firstScopeFromApi()).engine || 'postgresql'; }); test.afterEach(async ({}, testInfo) => { testLogger.testEnd(testInfo.title, testInfo.status); }); // ========================================================================= // POSITIVE: every tab loads and resolves // ========================================================================= for (const { tab, segment } of ALL_TABS) { test(`P0: ${tab} loads, resolves, and lands on its own route`, { tag: ['@dbm', '@infra', '@P0', '@all'], }, async ({ page }) => { await dbm.navigate(tab); await dbm.expectLoaded(); // The URL must match the tab asked for — a tab that silently redirects // (the enterprise guard bouncing to the overview, say) would otherwise // pass every content assertion below while showing a different page. const expected = segment ? `/infra/databases/${segment}` : '/infra/databases'; expect(page.url(), `${tab} did not land on ${expected}`).toContain(expected); const outcome = await dbm.settleTab(tab); expect( outcome, `${tab} never resolved — no rows and no declared empty state`, ).not.toBe('timeout'); const rows = await dbm.rowsOn(tab); testLogger.info(`${tab}: outcome=${outcome} rows=${rows}`); }); } test('P0: every tab in the strip is reachable by clicking it', { tag: ['@dbm', '@infra', '@P0', '@all'], }, async ({ page }) => { await dbm.navigate('overview'); await dbm.expectLoaded(); // The click path, not the URL path — a tab can be routable by URL while // its strip entry is mis-wired, and only a real click catches that. for (const { tab, segment } of ALL_TABS) { const locator = dbm.tabLocator(tab); if (!(await locator.count())) { // Deadlocks / Blocked / Table health are enterprise-gated: absent on // an OSS build. Absent is legitimate; broken is not. testLogger.info(`${tab} not present in the strip (gated) — skipping`); continue; } await locator.click(); const expected = segment ? `/infra/databases/${segment}` : '/infra/databases'; await expect(page, `clicking ${tab} did not navigate`).toHaveURL( new RegExp(escapeRegex(expected) + '(\\?|$)'), { timeout: 20000 }, ); await dbm.expectLoaded(); } }); // ========================================================================= // POSITIVE: search narrows, and clearing restores // ========================================================================= for (const tab of SEARCHABLE_TABS) { test(`P1: ${tab} search narrows the list and clearing restores it`, { tag: ['@dbm', '@infra', '@P1', '@all'], }, async () => { await dbm.navigate(tab); await dbm.expectLoaded(); await dbm.settleTab(tab); const before = await dbm.rowsOn(tab); test.skip(before === 0, `${tab} has no rows to search in this window`); // A string no real query, table or engine can contain. await dbm.search(tab, 'zzz-no-such-thing-zzz'); const narrowed = await dbm.rowsOn(tab); testLogger.info(`${tab}: before=${before} afterNonsenseSearch=${narrowed}`); // Search must actually filter. Equal counts mean the box is decorative — // which is a real defect and one users hit immediately. expect( narrowed, `${tab} search did not narrow anything (still ${narrowed} rows)`, ).toBeLessThan(before); await dbm.search(tab, ''); await dbm.settleTab(tab); const restored = await dbm.rowsOn(tab); testLogger.info(`${tab}: afterClear=${restored}`); // Restoring need not hit the exact prior number — the rig ingests // continuously — but the list must come back. expect(restored, `${tab} did not recover after clearing the search`).toBeGreaterThan(0); }); } // ========================================================================= // NEGATIVE: a search that matches nothing explains itself // ========================================================================= test('P1: a no-match search shows a stated empty state, not a blank table', { tag: ['@dbm', '@infra', '@P1', '@negative', '@all'], }, async () => { await dbm.navigate('tableHealth'); await dbm.expectLoaded(); await dbm.settleTab('tableHealth'); test.skip((await dbm.rowsOn('tableHealth')) === 0, 'no table-health rows in this window'); await dbm.search('tableHealth', 'zzz-no-such-table-zzz'); expect(await dbm.rowsOn('tableHealth')).toBe(0); // Zero rows is correct here. The requirement is that the page SAYS so — // "no matches" is a different message from "nothing is collecting", and // showing the wrong one sends the reader to debug a healthy collector. await expect( dbm.tableHealthNoMatches, 'a no-match search rendered an unexplained empty table', ).toBeVisible({ timeout: 15000 }); // And the section must remain navigable, not wedged in the empty state. await dbm.expectLoaded(); }); // ========================================================================= // NEGATIVE: malformed and hostile URL input // ========================================================================= test('P1: an unknown tab segment does not break the section', { tag: ['@dbm', '@infra', '@P1', '@negative', '@all'], }, async ({ page }) => { const org = process.env['ORGNAME'] || 'default'; const baseUrl = (process.env['ZO_BASE_URL'] || '').replace(/\/+$/, ''); await page.goto(`${baseUrl}/web/infra/databases/no-such-tab?org_identifier=${org}&period=1h`, { timeout: 60000, }); await page.waitForLoadState('load', { timeout: 20000 }); // Either a 404 view or a redirect into the section is acceptable; a blank // page or a hung spinner is not. The app must still be usable. const body = await dbm.getBodyText(); expect(body.trim().length, 'unknown DBM route rendered a blank page').toBeGreaterThan(0); testLogger.info(`unknown segment landed on ${page.url()}`); }); test('P1: junk values in scope params leave the page usable', { tag: ['@dbm', '@infra', '@P1', '@negative', '@all'], }, async ({ page }) => { // Quotes and angle brackets: if any of this reaches SQL or the DOM // unescaped, this is where it shows up. await dbm.navigateWithScope('tableHealth', { instance: `'; DROP TABLE x; --`, system: '', }); await dbm.expectLoaded(); const outcome = await dbm.settleTab('tableHealth'); expect(outcome, 'junk scope params wedged the page').not.toBe('timeout'); // Same ordering rule as the unmatched-scope tests: the empty state is // gated on `!loading`, so waiting for it proves the fetch finished and the // table is converged. Here the state is `engine-unsupported` — an // unrecognised `system=` is not a supported engine, which is the correct // and self-explaining answer. expect( await dbm.waitForExplanation('tableHealth'), 'junk scope params produced no explanation', ).toBeTruthy(); // Nothing can legitimately match these, so rows must be zero — a non-zero // count would mean the filter was ignored rather than applied. expect(await dbm.rowsOn('tableHealth'), 'junk filters returned rows').toBe(0); // The injected markup must not have executed or been rendered as an element. expect( await dbm.getInjectedScriptCount(), 'injected script tag was rendered into the page', ).toBe(0); }); test('P1: an inverted time range does not hang the page', { tag: ['@dbm', '@infra', '@P1', '@negative', '@all'], }, async ({ page }) => { const org = process.env['ORGNAME'] || 'default'; const baseUrl = (process.env['ZO_BASE_URL'] || '').replace(/\/+$/, ''); // end BEFORE start — the API cannot answer this meaningfully. const now = Date.now() * 1000; await page.goto( `${baseUrl}/web/infra/databases/table-health?org_identifier=${org}` + `&from=${now}&to=${now - 3600 * 1e6}`, { timeout: 60000 }, ); await page.waitForLoadState('load', { timeout: 20000 }); // The bar is that the shell survives and the reader can navigate away — // not that the numbers mean anything. await dbm.expectLoaded(); testLogger.info(`inverted range landed on ${page.url()}`); }); // ========================================================================= // POSITIVE: pagination // ========================================================================= test('P1: paging forward shows different rows and paging back returns', { tag: ['@dbm', '@infra', '@P1', '@all'], }, async () => { // Activity is the reliably-paginated tab on this rig (100 rows, 20/page). await dbm.navigate('activity'); await dbm.expectLoaded(); await dbm.settleTab('activity'); const firstPageRows = await dbm.rowsOn('activity'); test.skip(firstPageRows === 0, 'no activity rows in this window'); const canPage = (await dbm.nextPageBtn.count()) > 0 && (await dbm.nextPageBtn.isEnabled().catch(() => false)); test.skip(!canPage, 'activity fits on one page — nothing to paginate'); // Compare the PAGE INDICATOR, not the rendered text. // // Row text is not a valid page signature on every engine. MariaDB reports // `supports_query_sample_text: false`, so its samples carry no distinct // statement text: measured on the rig, 80 activity rows collapse to TWO // distinct (session, query) pairs — 79 of them the same hot session running // the same UPDATE. Page 2 then renders text identical to page 1 while // paging perfectly correctly, and a text diff calls that a bug. // // What must be true regardless of engine is that the control MOVED and the // table still holds rows. const firstRows = await dbm.rowsOn('activity'); await dbm.nextPageBtn.click(); await dbm.page.waitForTimeout(1200); const pagerText = await dbm.paginationBottom .first() .innerText() .catch(() => ''); testLogger.info(`after next: pager="${pagerText.replace(/\s+/g, ' ')}"`); expect(await dbm.rowsOn('activity'), 'page 2 rendered no rows').toBeGreaterThan(0); expect(firstRows, 'page 1 rendered no rows').toBeGreaterThan(0); await dbm.prevPageBtn.click(); await dbm.page.waitForTimeout(1200); const backRows = await dbm.rowsOn('activity'); expect(backRows, 'paging back lost the rows').toBeGreaterThan(0); testLogger.info(`activity paging: page1=${firstPageRows} back=${backRows}`); }); // ========================================================================= // POSITIVE: deep links carry their whole state // ========================================================================= test('P1: a scoped deep link reproduces its scope after a reload', { tag: ['@dbm', '@infra', '@P1', '@all'], }, async ({ page }) => { await dbm.navigateWithScope('tableHealth', { system: engine }); await dbm.expectLoaded(); await dbm.settleTab('tableHealth'); const before = await dbm.rowsOn('tableHealth'); // A permalink is only useful if reopening it shows the same thing. This // catches scope held in memory but never written to (or read back from) // the URL. await page.reload({ timeout: 60000 }); await page.waitForLoadState('load', { timeout: 20000 }); await dbm.expectLoaded(); await dbm.settleTab('tableHealth'); // The PARAM, not a substring of the URL — on a MySQL org the identifier // itself contains "mysql", so a substring check would pass even if the // scope had been dropped entirely. expect( new URL(page.url()).searchParams.get('system'), 'the scope was dropped from the URL on reload', ).toBe(engine); const after = await dbm.rowsOn('tableHealth'); testLogger.info(`reload: before=${before} after=${after}`); // Same scope, same window, continuously-ingesting rig: the row count may // drift, but data must not vanish. if (before > 0) expect(after, 'a reload emptied a populated table').toBeGreaterThan(0); }); // ========================================================================= // NEGATIVE: a scope that matches nothing, on every tab that accepts one // ========================================================================= for (const tab of ['tableHealth', 'activity', 'deadlocks']) { test(`P1: ${tab} handles a scope that matches nothing`, { tag: ['@dbm', '@infra', '@P1', '@negative', '@all'], }, async () => { await dbm.navigateWithScope(tab, { instance: 'instance-that-cannot-exist-12345', system: engine, }); await dbm.expectLoaded(); const outcome = await dbm.settleTab(tab); expect(outcome, `${tab} hung on an unmatched scope`).not.toBe('timeout'); // ORDER MATTERS: wait for the EXPLANATION first, then count rows. // // Every empty state on these pages is gated on `!loading` // (DeadlocksPage.vue:369,378), so its appearance is proof the fetch // finished — at which point the table is converged and a row count is // trustworthy. Counting first is what made this flaky: the shell is // keep-alive, so arriving on the tab repaints the PREVIOUS run's rows // for up to ~1s, and under the full parallel suite the real (empty) // response lands slowly enough that a count sampled early reads those // stale rows. Verified against the API: /deadlocks returns 0/0 for this // instance on every one of 30 consecutive calls, so a non-zero read here // was never the backend leaking. const explained = await dbm.waitForExplanation(tab); expect(explained, `${tab} showed no rows and no explanation`).toBeTruthy(); const rows = await dbm.rowsOn(tab); expect(rows, `${tab} returned rows for an instance that does not exist`).toBe(0); }); } // ========================================================================= // POSITIVE: clearing filters // ========================================================================= test('P1: Clear all is reachable without opening the dropdown and resets the scope', { tag: ['@dbm', '@infra', '@P1', '@all'], }, async ({ page }) => { // Arrive already filtered, the way a pasted permalink does. await dbm.navigateWithScope('overview', { system: engine }); await dbm.expectLoaded(); await dbm.settleTab('overview'); await expect( dbm.scopeChip, 'the scope chip did not render for an applied filter', ).toBeVisible({ timeout: 15000 }); // The point of the inline control: resetting the view must not require // hunting inside the Filters popover for a button that acts on the chips // already on screen. await expect( dbm.scopeClearInline, 'no inline Clear all beside the chips', ).toBeVisible({ timeout: 15000 }); await dbm.scopeClearInline.click(); await dbm.settleTab('overview'); // Cleared means cleared in BOTH places the scope lives — the chip row and // the URL. A chip that disappears while the query string still carries the // filter comes back on the next reload. await expect(dbm.scopeChip, 'the chip survived Clear all').toBeHidden({ timeout: 15000 }); // Assert on the PARAM, not on the URL as a substring: the org identifier // is in the same URL, and `mysql_server` contains "mysql", so a substring // check reports a filter that was cleared correctly as still present. expect( new URL(page.url()).searchParams.get('system'), 'Clear all left the system filter in the URL', ).toBeNull(); }); test('P1: Clear all also empties the search box on the overview', { tag: ['@dbm', '@infra', '@P1', '@all'], }, async () => { await dbm.navigateWithScope('overview', { system: engine }); await dbm.expectLoaded(); await dbm.settleTab('overview'); await dbm.search('overview', 'zzz-no-such-database-zzz'); const field = dbm.searchField('overview'); expect(await field.inputValue()).toBe('zzz-no-such-database-zzz'); await dbm.scopeClearInline.click(); await dbm.settleTab('overview'); // The overview used to clear only the engine filter, leaving the list // narrowed by a search term with no visible reason — "clear" meaning // something different here than on every sibling tab. expect( await field.inputValue(), 'Clear all left the search box populated', ).toBe(''); }); test('P0: Clear all on the overview clears the scope for the OTHER tabs too', { tag: ['@dbm', '@infra', '@P0', '@all'], }, async ({ page }) => { // Arrive carrying a dimension the OVERVIEW has no control for. This is // exactly how a permalink from Slowest calls or Table health arrives, and // it is the case that used to survive a "Clear all": the chip row emptied, // but `instance` stayed in the URL and re-seeded the next tab, so the // section was still filtered by something nothing on screen mentioned. const instance = (await dbm.firstInstanceFromApi()) || 'postgres'; await dbm.navigateWithScope('overview', { system: engine, instance }); await dbm.expectLoaded(); await dbm.settleTab('overview'); await expect(dbm.scopeClearInline).toBeVisible({ timeout: 15000 }); await dbm.scopeClearInline.click(); await dbm.settleTab('overview'); // The URL is the section's shared scope — every tab re-seeds from it on // activation — so a cleared section must not leave ANY dimension behind. const url = page.url(); for (const param of ['system=', 'instance=', 'namespace=', 'env=', 'service=', 'search=']) { expect(url, `Clear all left ${param} in the URL`).not.toContain(param); } // And the proof that matters to the reader: switch tabs and find no chip. await dbm.openTab('tableHealth'); await dbm.expectLoaded(); await dbm.settleTab('tableHealth'); await expect( dbm.scopeChip, 'a sibling tab re-applied a filter the overview had just cleared', ).toBeHidden({ timeout: 15000 }); expect(page.url(), 'the sibling tab restored a cleared dimension').not.toContain('instance='); }); // ========================================================================= // NEGATIVE: a missing VANTAGE is not a missing ENTITLEMENT // ========================================================================= test('P0: Callers explains a missing trace vantage instead of showing a padlock', { tag: ['@dbm', '@infra', '@P0', '@negative', '@all'], }, async ({ page }) => { // The padlock means "not in your plan". Callers is not gated by build // type: on an enterprise deployment it is empty only because nothing // traced the application — a fixable setup state. Locking it there told a // customer to upgrade a plan they already had, and left no room for the // instructions that would actually help. const fingerprint = await dbm.firstServerQueryFingerprint(); test.skip(!fingerprint, 'no server-reported query in this window to open'); await dbm.openQueryDetail(fingerprint); const callersTab = dbm.detailTab('callers'); await expect(callersTab, 'the callers tab did not render').toBeVisible({ timeout: 30000 }); const hasTraces = await dbm.orgHasClientVantage(); test.skip( hasTraces, 'this org HAS a trace vantage — point ORGNAME at a no-traces org to exercise the empty state', ); // Not locked, and reachable. expect( await dbm.detailTabLock('callers').count(), 'callers wore the entitlement padlock for a data-only gap', ).toBe(0); expect(await callersTab.getAttribute('aria-disabled')).not.toBe('true'); await callersTab.click(); // Empty, but INSTRUCTIVE — it names what is missing and offers a route to // fix it, rather than rendering a blank panel that reads as "nothing calls // this query" (which would be false). await expect( dbm.detailCallersEmpty, 'callers rendered no explanation for the missing trace vantage', ).toBeVisible({ timeout: 20000 }); const text = (await dbm.detailCallersEmpty.textContent()) || ''; expect(text.trim().length, 'the empty state carried no copy').toBeGreaterThan(40); testLogger.info(`callers empty state: ${text.replace(/\s+/g, ' ').slice(0, 120)}`); }); // ========================================================================= // POSITIVE: badges are internally consistent // ========================================================================= test('P1: no badge reports a negative or non-numeric count', { tag: ['@dbm', '@infra', '@P1', '@all'], }, async () => { await dbm.navigate('overview'); await dbm.expectLoaded(); // Let the strip's fan-out land before reading any of it. await dbm.getBadgeCount('tableHealth').catch(() => null); for (const { tab } of ALL_TABS) { const badge = await dbm.getBadgeCount(tab); if (badge === null) continue; expect(Number.isFinite(badge), `${tab} badge is not a number`).toBeTruthy(); expect(badge, `${tab} badge is negative`).toBeGreaterThanOrEqual(0); testLogger.info(`badge ${tab} = ${badge}`); } }); });