mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
The Backups drawer's Identity and History target is the merged PBS host resource. Correlation was recomputed from each snapshot with no memory, so a live refresh that briefly omitted the correlated host row made the drawer fall back to the PBS service target (agent/<service key>), which has no host history. The target flicked between the host series and the service key until the host row returned. Retain the last resolved correlation per PBS server and reuse it only across such an omission, and only while the remembered host is still fresh relative to the server, so a genuinely removed or replaced host is not advertised indefinitely. A host row that is present but ambiguous still declines, as before. Adds unit coverage for retention, staleness and pruning, a drawer regression test, and extends the real-browser guard and fixture to omit the host row and assert the identity rows and History chart survive. Change-source: pulse-maintainer
366 lines
13 KiB
JavaScript
366 lines
13 KiB
JavaScript
// Offline real-browser regression for #1723.
|
|
//
|
|
// 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(),
|
|
process.env.PBS_HISTORY_SOURCE_ROOT || '.',
|
|
'frontend-modern',
|
|
);
|
|
const launchOptions = {
|
|
headless: true,
|
|
channel: 'chromium',
|
|
args: ['--no-sandbox'],
|
|
};
|
|
|
|
(async () => {
|
|
process.chdir(ROOT);
|
|
const { createServer } = await import(
|
|
path.join(ROOT, 'node_modules', 'vite', 'dist', 'node', 'index.js')
|
|
);
|
|
const server = await createServer({
|
|
root: ROOT,
|
|
configFile: path.join(ROOT, 'vite.config.ts'),
|
|
server: { host: '127.0.0.1', port: 5198, strictPort: true },
|
|
});
|
|
let browser;
|
|
const results = [];
|
|
const pageErrors = [];
|
|
const failures = [];
|
|
const topologies = ['pbs-only', 'side-by-side', 'guest'];
|
|
const wrong = 'agent/pbs-1';
|
|
// Reporter avsdev-cw sees the Identity card's "Discovery" and "Metrics
|
|
// Target" rows flick between a service id and the correlated host id while
|
|
// the table sits idle. Read the rendered rows so a snapshot-driven identity
|
|
// change fails the check instead of only being visible to a human.
|
|
const readIdentityRows = (detail) =>
|
|
detail
|
|
.locator('[data-testid="resource-identity-section"] tr')
|
|
.evaluateAll((rows) =>
|
|
rows
|
|
.map((row) => {
|
|
const cells = row.querySelectorAll('td');
|
|
return cells.length >= 2
|
|
? [cells[0].textContent.trim(), cells[1].textContent.trim()]
|
|
: null;
|
|
})
|
|
.filter((row) => row && row[0]),
|
|
);
|
|
try {
|
|
await server.listen();
|
|
browser = await chromium.launch(launchOptions);
|
|
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/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.
|
|
let retainedIdentity = [];
|
|
let retainedPaths = 0;
|
|
const detail = page.locator(
|
|
'[data-inline-platform-resource-detail-for="pbs-1"]',
|
|
);
|
|
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);
|
|
await detail.evaluate((node) => {
|
|
node.dataset.proofIdentity = 'retained';
|
|
});
|
|
const pathsBefore = await detail
|
|
.locator('[data-testid="guest-history-plot"] path')
|
|
.count();
|
|
assert.ok(pathsBefore > 0);
|
|
const identityBefore = await readIdentityRows(detail);
|
|
const identityLabels = identityBefore.map(([label]) => label);
|
|
assert.ok(
|
|
identityLabels.includes('Discovery'),
|
|
'Discovery identity row missing from the drawer',
|
|
);
|
|
assert.ok(
|
|
identityLabels.includes('Metrics Target'),
|
|
'Metrics Target identity row missing from the drawer',
|
|
);
|
|
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,
|
|
);
|
|
const identityAfter = await readIdentityRows(detail);
|
|
assert.deepEqual(
|
|
identityAfter,
|
|
identityBefore,
|
|
'Identity rows changed after a refreshed snapshot',
|
|
);
|
|
retainedIdentity = identityAfter;
|
|
retainedPaths = pathsBefore;
|
|
observations.push({
|
|
datastore,
|
|
paths: pathsBefore,
|
|
identity: identityAfter,
|
|
refreshPreservedHistory: true,
|
|
});
|
|
}
|
|
|
|
// A live refresh can briefly omit the correlated host row while the
|
|
// PBS server row stays. That must not flip the drawer's Identity or
|
|
// History target to the PBS service key (#1723).
|
|
await page.getByRole('button', { name: 'Drop correlated host' }).click();
|
|
await page
|
|
.getByRole('status', { name: 'Snapshot number' })
|
|
.filter({ hasText: '4' })
|
|
.waitFor();
|
|
assert.equal(
|
|
await detail.getAttribute('data-proof-identity'),
|
|
'retained',
|
|
'drawer remounted after the host row was omitted',
|
|
);
|
|
assert.equal(
|
|
await page
|
|
.getByRole('tab', { name: 'History', exact: true })
|
|
.getAttribute('aria-selected'),
|
|
'true',
|
|
'history reset after the host row was omitted',
|
|
);
|
|
assert.equal(
|
|
await detail
|
|
.locator('[data-testid="guest-history-plot"] path')
|
|
.count(),
|
|
retainedPaths,
|
|
'history chart lost its series when the host row was omitted',
|
|
);
|
|
assert.deepEqual(
|
|
await readIdentityRows(detail),
|
|
retainedIdentity,
|
|
'Identity rows changed when the host row was transiently omitted',
|
|
);
|
|
observations.push({
|
|
hostRowOmitted: true,
|
|
identity: retainedIdentity,
|
|
refreshPreservedHistory: true,
|
|
});
|
|
await page.getByRole('button', { name: 'Restore correlated host' }).click();
|
|
await page
|
|
.getByRole('status', { name: 'Snapshot number' })
|
|
.filter({ hasText: '5' })
|
|
.waitFor();
|
|
|
|
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.close();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pageErrors.length > 0) {
|
|
throw new Error(`page errors: ${pageErrors.join(' | ')}`);
|
|
}
|
|
|
|
console.log(
|
|
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();
|
|
}
|
|
})().catch((error) => {
|
|
console.error('FAILED:', error && error.stack ? error.stack : error);
|
|
process.exitCode = 1;
|
|
});
|