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
This commit is contained in:
pulse-triage[bot] 2026-09-22 13:07:13 +01:00
parent f91ef61a53
commit da1dddc1af
2 changed files with 283 additions and 159 deletions

View file

@ -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<typeof setTimeout> | 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 (
<>
<button onClick={refresh}>Refresh resource snapshot</button>
<button
onClick={() => {
snapshot += 1;
const next = structuredClone([pbs, guest, standalone]);
next[1].cpu = { current: 15.4 + snapshot };
const tank = next[0].pbs!.datastores!.find((store) => store.name === 'tank')!;
tank.used = 400 + snapshot * 10;
tank.available = 600 - snapshot * 10;
tank.usagePercent = 40 + snapshot;
if (snapshot % 2 === 1) next[0].pbs!.datastores!.reverse();
setServers(next);
clearTimeout(timer);
timer = setTimeout(refresh, 100);
}}
>
Refresh resource snapshot
Schedule automatic snapshot
</button>
<output aria-label="Snapshot number">{snapshot()}</output>
<ProxmoxBackupServersTable servers={servers()} />
</>
);

View file

@ -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();