mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
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:
parent
f91ef61a53
commit
da1dddc1af
2 changed files with 283 additions and 159 deletions
|
|
@ -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()} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue