From da1dddc1af160cc302ad335ae3daad2c842c2ab2 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:07:13 +0100 Subject: [PATCH] test(web): cover PBS host topologies across automatic refresh Extend the PBS History regression to PBS-only and bare-metal PVE/PBS host shapes, stable and reordered snapshots, and desktop/mobile widths. Retain guest coverage and assert drawer identity, tab selection and history targets. This does not claim the separate reported tab reset is reproduced or resolved. Change-source: pulse-maintainer --- .../pbs-host-history-correlation.tsx | 60 ++- .../check-pbs-host-history-correlation.cjs | 382 +++++++++++------- 2 files changed, 283 insertions(+), 159 deletions(-) diff --git a/frontend-modern/browser-tests/pbs-host-history-correlation.tsx b/frontend-modern/browser-tests/pbs-host-history-correlation.tsx index 8b787ae09..cbb60a02a 100644 --- a/frontend-modern/browser-tests/pbs-host-history-correlation.tsx +++ b/frontend-modern/browser-tests/pbs-host-history-correlation.tsx @@ -3,8 +3,9 @@ // host row. Reproduces #1723: before the fix the two identity matches were // treated as ambiguous, so the PBS row kept its service metrics target and the // History tab showed "Collecting history" for a host that has history. +// Also covers PBS-only and co-installed bare-metal PVE/PBS with no guest. // Synthetic props only; the check script intercepts the metrics-history request. -import { createSignal } from 'solid-js'; +import { createSignal, onCleanup } from 'solid-js'; import { render } from 'solid-js/web'; import { ProxmoxBackupServersTable } from '../src/features/proxmox/ProxmoxBackupServersTable'; @@ -78,26 +79,59 @@ const standalone = { platformData: { sources: ['agent', 'pbs'], agent: sharedAgent }, } as unknown as Resource; +// Side-by-side services on one bare-metal host: the node is not a VM, and +// host history belongs to the standalone agent rather than a guest series. +const node = { + ...standalone, + id: 'node-proxback', + type: 'node', + platformId: 'proxmox/proxback', + platformType: 'proxmox-pve', + sources: ['proxmox', 'agent'], + metricsTarget: { resourceType: 'node', resourceId: 'proxmox/proxback' }, + platformData: { sources: ['proxmox', 'agent'], agent: sharedAgent }, +} as Resource; + +const query = new URLSearchParams(window.location.search); +const topology = query.get('topology') ?? 'guest'; +const resources = + topology === 'pbs-only' + ? [pbs, standalone] + : topology === 'side-by-side' + ? [pbs, node, standalone] + : [pbs, guest, standalone]; + const Fixture = () => { - const [servers, setServers] = createSignal(structuredClone([pbs, guest, standalone])); - let snapshot = 0; + const [servers, setServers] = createSignal(structuredClone(resources)); + const [snapshot, setSnapshot] = createSignal(0); + let timer: ReturnType | undefined; + onCleanup(() => clearTimeout(timer)); + const refresh = () => { + const count = snapshot() + 1; + const next = structuredClone(resources); + next[1].cpu = { current: 15.4 + count }; + const tank = next[0].pbs!.datastores!.find((store) => store.name === 'tank')!; + tank.used = 400 + count * 10; + tank.available = 600 - count * 10; + tank.usagePercent = 40 + count; + if (query.get('order') !== 'stable' && count % 2 === 1) { + next[0].pbs!.datastores!.reverse(); + } + setServers(next); + setSnapshot(count); + }; return ( <> + + {snapshot()} ); diff --git a/scripts/check-pbs-host-history-correlation.cjs b/scripts/check-pbs-host-history-correlation.cjs index 8e12ba2e1..6b48f45a6 100644 --- a/scripts/check-pbs-host-history-correlation.cjs +++ b/scripts/check-pbs-host-history-correlation.cjs @@ -1,19 +1,21 @@ // Offline real-browser regression for #1723. // -// Mounts the production ProxmoxBackupServersTable with a PBS host whose agent -// is present twice (PVE guest with agent telemetry, and a standalone source=pbs -// host row), plus two datastores. Switches between them, replaces/reorders -// snapshots with fresh CPU telemetry, and checks chart paths and tab retention. -// Records the metrics -// history target the drawer requests. The correct target is the guest's vm -// series; the PBS service target has no host history and leaves the tab on -// "Collecting history". Run with: -// pulse-worker-browser scripts/check-pbs-host-history-correlation.cjs +// Exercises PBS-only, bare-metal PVE/PBS side-by-side, and a PVE guest plus +// standalone representation of one agent. Each has two datastores. Switches +// rows and applies timer-driven fresh snapshots in stable and reversed order. +// Checks the drawer identity, selected History tab, populated chart paths and +// exact host metrics target at desktop/mobile widths. Synthetic API responses +// prove UI behaviour, not backend persistence or installed release behaviour. +// Run: pulse-worker-browser scripts/check-pbs-host-history-correlation.cjs const path = require('node:path'); const assert = require('node:assert/strict'); const { chromium } = require('playwright'); -const ROOT = path.resolve(process.cwd(), 'frontend-modern'); +const ROOT = path.resolve( + process.cwd(), + process.env.PBS_HISTORY_SOURCE_ROOT || '.', + 'frontend-modern', +); const launchOptions = { headless: true, channel: 'chromium', @@ -33,149 +35,227 @@ const launchOptions = { let browser; const results = []; const pageErrors = []; - const expected = 'vm/proxmox:100'; + const failures = []; + const topologies = ['pbs-only', 'side-by-side', 'guest']; const wrong = 'agent/pbs-1'; try { await server.listen(); browser = await chromium.launch(launchOptions); - for (const viewport of [ - { width: 1280, height: 900 }, - { width: 390, height: 844 }, - ]) { - const targets = []; - const page = await browser.newPage({ viewport }); - page.on('pageerror', (error) => pageErrors.push(`${viewport.width}px: ${error.message}`)); + for (const topology of topologies) { + for (const order of ['stable', 'reordered']) { + const expected = + topology === 'guest' ? 'vm/proxmox:100' : 'agent/agent-proxback'; + for (const viewport of [ + { width: 1280, height: 900 }, + { width: 390, height: 844 }, + ]) { + const targets = []; + const observations = []; + const caseName = `${topology}-${order}-${viewport.width}`; + const page = await browser.newPage({ viewport }); + page.on('pageerror', (error) => + pageErrors.push(`${viewport.width}px: ${error.message}`), + ); - await page.route('**/api/license/runtime-capabilities', (route) => - route.fulfill({ - json: { - capabilities: [], - limits: [], - hosted_mode: false, - max_history_days: 7, - runtime: { build: 'community', label: 'Pulse Community runtime' }, - blocked_capabilities: [], - }, - }), - ); - await page.route('**/api/settings/ai', (route) => - route.fulfill({ json: { enabled: false } }), - ); + await page.route('**/api/license/runtime-capabilities', (route) => + route.fulfill({ + json: { + capabilities: [], + limits: [], + hosted_mode: false, + max_history_days: 7, + runtime: { + build: 'community', + label: 'Pulse Community runtime', + }, + blocked_capabilities: [], + }, + }), + ); + await page.route('**/api/settings/ai', (route) => + route.fulfill({ json: { enabled: false } }), + ); - await page.route('**/api/metrics-store/history?**', async (route) => { - const query = new URL(route.request().url()).searchParams; - targets.push(`${query.get('resourceType')}/${query.get('resourceId')}`); - await route.fulfill({ - json: { - resourceType: query.get('resourceType'), - resourceId: query.get('resourceId'), - range: query.get('range'), - start: Date.now() - 3600000, - end: Date.now(), - source: 'store', - metrics: Object.fromEntries( - ['cpu', 'memory', 'disk', 'netin', 'netout', 'diskread', 'diskwrite'].map( - (metric) => [ - metric, - [30, 20, 10].map((minutes, i) => ({ - timestamp: Date.now() - minutes * 60000, - value: 10 + i * 5, - min: 10 + i * 5, - max: 10 + i * 5, - })), - ], + await page.route('**/api/metrics-store/history?**', async (route) => { + const query = new URL(route.request().url()).searchParams; + targets.push( + `${query.get('resourceType')}/${query.get('resourceId')}`, + ); + await route.fulfill({ + json: { + resourceType: query.get('resourceType'), + resourceId: query.get('resourceId'), + range: query.get('range'), + start: Date.now() - 3600000, + end: Date.now(), + source: 'store', + metrics: Object.fromEntries( + [ + 'cpu', + 'memory', + 'disk', + 'netin', + 'netout', + 'diskread', + 'diskwrite', + ].map((metric) => [ + metric, + [30, 20, 10].map((minutes, i) => ({ + timestamp: Date.now() - minutes * 60000, + value: 10 + i * 5, + min: 10 + i * 5, + max: 10 + i * 5, + })), + ]), + ), + }, + }); + }); + + try { + await page.goto( + `http://127.0.0.1:5198/browser-tests/pbs-host-history-correlation.html?topology=${topology}&order=${order}`, + { timeout: 60000 }, + ); + const rows = page.locator('tr').filter({ + has: page.getByRole('button', { + name: 'Expand details for proxback', + exact: true, + }), + }); + await rows.first().waitFor({ timeout: 20000 }); + const openDatastore = async (name) => { + const row = page.locator('tr').filter({ + has: page.locator(`td[title="proxback · ${name}"]`), + }); + await row.getByRole('button').focus(); + await page.keyboard.press('Enter'); + await page + .getByRole('tab', { name: 'History', exact: true }) + .click(); + await page + .locator('[data-testid="guest-history-plot"] path') + .first() + .waitFor(); + assert.equal( + await page + .getByRole('tab', { name: 'History', exact: true }) + .getAttribute('aria-selected'), + 'true', + ); + }; + // Both datastore rows must use the same host series, including revisiting + // the first row after its history component has been disposed. + for (const [index, datastore] of [ + 'tank', + 'archive', + 'tank', + ].entries()) { + assert.deepEqual( + await page + .locator('td[title^="proxback · "]') + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute('title')), + ), + ['proxback · archive', 'proxback · tank'], + ); + await openDatastore(datastore); + const detail = page.locator( + '[data-inline-platform-resource-detail-for="pbs-1"]', + ); + await detail.evaluate((node) => { + node.dataset.proofIdentity = 'retained'; + }); + const pathsBefore = await detail + .locator('[data-testid="guest-history-plot"] path') + .count(); + assert.ok(pathsBefore > 0); + await page + .getByRole('button', { name: 'Schedule automatic snapshot' }) + .click(); + // Return focus to History before the timer-driven data replacement. + await page + .getByRole('tab', { name: 'History', exact: true }) + .focus(); + await page + .getByRole('status', { name: 'Snapshot number' }) + .filter({ hasText: String(index + 1) }) + .waitFor(); + // Datastore usage is visible at both widths and proves that the fresh + // snapshot reached the UI (the CPU column is hidden on narrow screens). + await page + .getByText(`${(41 + index).toFixed(1)}%`, { exact: true }) + .first() + .waitFor(); + assert.equal( + await detail.getAttribute('data-proof-identity'), + 'retained', + 'drawer remounted', + ); + assert.equal( + await page + .getByRole('tab', { name: 'History', exact: true }) + .getAttribute('aria-selected'), + 'true', + 'history reset after snapshot', + ); + assert.equal( + await detail + .locator('[data-testid="guest-history-plot"] path') + .count(), + pathsBefore, + ); + observations.push({ + datastore, + paths: pathsBefore, + refreshPreservedHistory: true, + }); + } + + if (!targets.includes(expected)) { + throw new Error( + `${viewport.width}px: expected the host history target ${expected}; recorded ${JSON.stringify(targets)}`, + ); + } + if (targets.includes(wrong)) { + throw new Error( + `${viewport.width}px: PBS service target ${wrong} was requested instead of the host series`, + ); + } + + results.push({ + topology, + order, + viewport, + expected, + targets, + observations, + result: 'passed', + }); + } catch (error) { + failures.push(`${caseName}: ${error.message}`); + results.push({ + topology, + order, + viewport, + expected, + targets, + observations, + result: 'failed', + error: error.message, + }); + } finally { + await page.screenshot({ + path: path.join( + ROOT, + 'browser-tests', + `pbs-host-history-${caseName}.png`, ), - ), - }, - }); - }); - - await page.goto('http://127.0.0.1:5198/browser-tests/pbs-host-history-correlation.html'); - const rows = page.locator('tr').filter({ - has: page.getByRole('button', { - name: 'Expand details for proxback', - exact: true, - }), - }); - await rows.first().waitFor({ timeout: 20000 }); - const openDatastore = async (name) => { - const row = page - .locator('tr') - .filter({ has: page.locator(`td[title="proxback · ${name}"]`) }); - await row.getByRole('button').focus(); - await page.keyboard.press('Enter'); - await page.getByRole('tab', { name: 'History', exact: true }).click(); - await page.locator('[data-testid="guest-history-plot"] path').first().waitFor(); - assert.equal( - await page - .getByRole('tab', { name: 'History', exact: true }) - .getAttribute('aria-selected'), - 'true', - ); - }; - const observations = []; - // Both datastore rows must use the same host series, including revisiting - // the first row after its history component has been disposed. - for (const [index, datastore] of ['tank', 'archive', 'tank'].entries()) { - assert.deepEqual( - await page - .locator('td[title^="proxback · "]') - .evaluateAll((nodes) => nodes.map((node) => node.getAttribute('title'))), - ['proxback · archive', 'proxback · tank'], - ); - await openDatastore(datastore); - const detail = page.locator('[data-inline-platform-resource-detail-for="pbs-1"]'); - await detail.evaluate((node) => { - node.dataset.proofIdentity = 'retained'; - }); - const pathsBefore = await detail.locator('[data-testid="guest-history-plot"] path').count(); - assert.ok(pathsBefore > 0); - await page.getByRole('button', { name: 'Refresh resource snapshot' }).click(); - // Datastore usage is visible at both widths and proves that the fresh - // snapshot reached the UI (the CPU column is hidden on narrow screens). - await page - .getByText(`${(41 + index).toFixed(1)}%`, { exact: true }) - .first() - .waitFor(); - assert.equal( - await detail.getAttribute('data-proof-identity'), - 'retained', - 'drawer remounted', - ); - assert.equal( - await page - .getByRole('tab', { name: 'History', exact: true }) - .getAttribute('aria-selected'), - 'true', - 'history reset after snapshot', - ); - assert.equal( - await detail.locator('[data-testid="guest-history-plot"] path').count(), - pathsBefore, - ); - observations.push({ - datastore, - paths: pathsBefore, - refreshPreservedHistory: true, - }); + }); + await page.close(); + } + } } - - if (!targets.includes(expected)) { - throw new Error( - `${viewport.width}px: expected the guest history target ${expected}; recorded ${JSON.stringify(targets)}`, - ); - } - if (targets.includes(wrong)) { - throw new Error( - `${viewport.width}px: PBS service target ${wrong} was requested instead of the guest series`, - ); - } - - await page.screenshot({ - path: path.join(ROOT, 'browser-tests', `pbs-host-history-${viewport.width}.png`), - }); - results.push({ viewport, targets, observations }); - await page.close(); } if (pageErrors.length > 0) { @@ -183,8 +263,18 @@ const launchOptions = { } console.log( - JSON.stringify({ result: 'passed', browser: browser.version(), expected, results }, null, 2), + JSON.stringify( + { + result: failures.length ? 'failed' : 'passed', + browser: browser.version(), + results, + failures, + }, + null, + 2, + ), ); + assert.equal(failures.length, 0, failures.join(' | ')); } finally { if (browser) await browser.close(); await server.close();