mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
test(web): qualify backup refresh through application boundaries
The isolated Coverage fixture cannot establish whether resource-provider and WebSocket replacement preserves application interaction in issue #1869. Exercise the actual shell, scoped paginated resources, windowed Coverage and By date, and the real PBS History drawer with synthetic snapshots. Retain desktop and narrow evidence without claiming a deployed-release or Brave fix. Change-source: pulse-maintainer
This commit is contained in:
@@ -21,3 +21,37 @@ This covers #1869's refresh stability at the coverage-table boundary. It does
|
||||
**not** qualify the full application resource provider/WebSocket path, PBS drawer,
|
||||
By date view, virtualised large tables, Settings editing, or Brave. A passing
|
||||
result is not grounds to close #1869 or claim the reported top-of-page jump fixed.
|
||||
|
||||
## Application refresh boundary
|
||||
|
||||
```sh
|
||||
pulse-heavy-run -- node scripts/check-backup-app-refresh.mjs
|
||||
pulse-heavy-run -- env PULSE_BROWSER_WIDTH=390 node scripts/check-backup-app-refresh.mjs
|
||||
```
|
||||
|
||||
This separate runner loads the actual application entry point, authentication
|
||||
bootstrap, router, scoped/paginated resource queries, WebSocket store and PBS
|
||||
resource drawer. Authentication and all API/socket data are synthetic; no live
|
||||
Pulse server or credentials are used. The browser blocks off-origin HTTP traffic.
|
||||
It supplies 240 workloads and a hybrid PBS resource with a metrics-history target.
|
||||
Three replacement `rawData` frames per view change rendered names, rather than
|
||||
merely waiting on a timer with unchanged content.
|
||||
|
||||
Assertions cover:
|
||||
- Coverage: fewer rendered rows than the inventory (windowing enabled), non-zero
|
||||
scroll, expanded restore evidence, focused expansion button and route retained.
|
||||
- By date: changed workload name, non-zero scroll, focused navigation link and route
|
||||
retained. This view has no per-artifact expansion toggle.
|
||||
- PBS: real History panel remains visible and selected, with its tab focused and
|
||||
route/scroll retained while the PBS name changes. Its baseline scroll is zero;
|
||||
this checks drawer state, not a non-zero drawer-scroll regression.
|
||||
- No uncaught page errors or error-level browser console messages.
|
||||
|
||||
Printed samples record scroll offsets and active controls. The fixed 500ms waits
|
||||
allow initial layout/scroll synchronisation; each refresh waits for changed text.
|
||||
The backup artifact HTTP payload itself is static after initial loading: this is
|
||||
resource-provider/socket replacement qualification, not backup API refetch proof.
|
||||
Narrow-width interaction is keyboard-driven, not touch or Brave qualification.
|
||||
The test does not cover Settings Manage editing, Authentication strategy selection,
|
||||
expansion padding, arbitrary inventory churn/reordering, or a deployed release.
|
||||
Passing is not grounds to close the mixed report #1869.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"version": 1,
|
||||
"verified_at": "2026-09-05T00:55:39.636242+00:00",
|
||||
"application_source_sha": "0c4486102e918a6800fb317166232224fd092a0d",
|
||||
"runner": "scripts/check-backup-app-refresh.mjs",
|
||||
"runner_sha256": "0b1868aa9f2f288fb07153e10abb3622140357a4c1600d1ed40b59f1c3b22b3d",
|
||||
"result": "passed",
|
||||
"environment": "Headless Playwright Chromium; synthetic scoped REST and replacement WebSocket snapshots; actual application entry point",
|
||||
"runs": [
|
||||
{
|
||||
"viewport": {
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
"coverage": {
|
||||
"baseline": {
|
||||
"y": 775,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/coverage"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"generation": 1,
|
||||
"y": 775,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/coverage"
|
||||
},
|
||||
{
|
||||
"generation": 2,
|
||||
"y": 775,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/coverage"
|
||||
},
|
||||
{
|
||||
"generation": 3,
|
||||
"y": 775,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/coverage"
|
||||
}
|
||||
],
|
||||
"visibleCoverageRows": 140,
|
||||
"pageErrors": [],
|
||||
"consoleErrors": []
|
||||
},
|
||||
"other_views": [
|
||||
{
|
||||
"view": "date",
|
||||
"baseline": {
|
||||
"y": 989,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "By date"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"y": 989,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "By date"
|
||||
},
|
||||
{
|
||||
"y": 989,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "By date"
|
||||
},
|
||||
{
|
||||
"y": 989,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "By date"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "PBS History",
|
||||
"baseline": {
|
||||
"y": 0,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "History"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"y": 0,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "History"
|
||||
},
|
||||
{
|
||||
"y": 0,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "History"
|
||||
},
|
||||
{
|
||||
"y": 0,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "History"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 900
|
||||
},
|
||||
"coverage": {
|
||||
"baseline": {
|
||||
"y": 636,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/coverage"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"generation": 1,
|
||||
"y": 636,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/coverage"
|
||||
},
|
||||
{
|
||||
"generation": 2,
|
||||
"y": 636,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/coverage"
|
||||
},
|
||||
{
|
||||
"generation": 3,
|
||||
"y": 636,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/coverage"
|
||||
}
|
||||
],
|
||||
"visibleCoverageRows": 36,
|
||||
"pageErrors": [],
|
||||
"consoleErrors": []
|
||||
},
|
||||
"other_views": [
|
||||
{
|
||||
"view": "date",
|
||||
"baseline": {
|
||||
"y": 900,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "By date"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"y": 900,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "By date"
|
||||
},
|
||||
{
|
||||
"y": 900,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "By date"
|
||||
},
|
||||
{
|
||||
"y": 900,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "By date"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "PBS History",
|
||||
"baseline": {
|
||||
"y": 0,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "History"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"y": 0,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "History"
|
||||
},
|
||||
{
|
||||
"y": 0,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "History"
|
||||
},
|
||||
{
|
||||
"y": 0,
|
||||
"url": "http://127.0.0.1:5198/proxmox/backups/date",
|
||||
"active": "History"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"limitations": [
|
||||
"Not a deployed release or reporter Brave retest; Brave executable not available on PATH",
|
||||
"Keyboard interaction at narrow width, not touch",
|
||||
"Static backup-artifact API after initial load; replacement resource snapshots only",
|
||||
"PBS History baseline scroll is zero; no non-zero drawer-scroll proof",
|
||||
"No Settings Manage editing, Authentication strategy or expansion-padding qualification",
|
||||
"No full repository suite or build run; no product code changed"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// Run with pulse-heavy-run -- node scripts/check-backup-app-refresh.mjs
|
||||
// Full application shell with synthetic API and WebSocket snapshots; no live deployment.
|
||||
import { createServer } from "../frontend-modern/node_modules/vite/dist/node/index.js";
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = fileURLToPath(new URL("../frontend-modern", import.meta.url));
|
||||
process.chdir(root); // PostCSS/Tailwind resolve their config from the frontend root.
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: `${root}/vite.config.ts`,
|
||||
server: { host: "127.0.0.1", port: 5198, strictPort: true },
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({
|
||||
viewport: {
|
||||
width: Number(process.env.PULSE_BROWSER_WIDTH || 1440),
|
||||
height: 900,
|
||||
},
|
||||
});
|
||||
// Never allow this synthetic qualification to contact a real service.
|
||||
await page.route("**/*", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== "http://127.0.0.1:5198") return route.abort();
|
||||
return route.continue();
|
||||
});
|
||||
const errors = [];
|
||||
const consoleErrors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("requestfailed", (request) =>
|
||||
console.error(request.url(), request.failure()),
|
||||
);
|
||||
let generation = 0;
|
||||
const workloads = () =>
|
||||
Array.from({ length: 240 }, (_, index) => ({
|
||||
id: `ct-${112 + index}`,
|
||||
type: "system-container",
|
||||
name: `guest-${String(index).padStart(2, "0")}-snapshot-${generation}`,
|
||||
displayName: `guest-${String(index).padStart(2, "0")}-snapshot-${generation}`,
|
||||
platformId: "pve-a",
|
||||
platformType: "proxmox-pve",
|
||||
sourceType: "api",
|
||||
status: "running",
|
||||
lastSeen: Date.now(),
|
||||
proxmox: { vmid: 112 + index, node: "pve-a", instance: "pve-a" },
|
||||
}));
|
||||
const pbs = () => ({
|
||||
id: "pbs-1",
|
||||
type: "pbs",
|
||||
name: `pbs-main-snapshot-${generation}`,
|
||||
displayName: "PBS Main",
|
||||
platformId: "pbs-main",
|
||||
platformType: "proxmox-pbs",
|
||||
sourceType: "hybrid",
|
||||
metricsTarget: { resourceType: "agent", resourceId: "agent-pbs-1" },
|
||||
agent: { agentId: "agent-pbs-1", hostname: "pbs-main" },
|
||||
status: "online",
|
||||
lastSeen: Date.now(),
|
||||
cpu: { current: 12 + generation },
|
||||
memory: { current: 40, total: 8000, used: 3200 },
|
||||
pbs: {
|
||||
instanceId: "pbs-main",
|
||||
version: `3.2.${generation}`,
|
||||
datastores: [],
|
||||
},
|
||||
platformData: { pbs: { instanceId: "pbs-main", hostname: "pbs-main" } },
|
||||
});
|
||||
const state = () => ({
|
||||
resources: [...workloads(), pbs()],
|
||||
connectedInfrastructure: [],
|
||||
activeAlerts: [],
|
||||
recentlyResolved: [],
|
||||
metrics: [],
|
||||
lastUpdate: Date.now(),
|
||||
stats: { version: "6.5.0-test", startTime: new Date().toISOString() },
|
||||
});
|
||||
let socket;
|
||||
await page.routeWebSocket(/\/ws(?:\?|$)/, (ws) => {
|
||||
socket = ws;
|
||||
ws.send(JSON.stringify({ type: "initialState", data: state() }));
|
||||
});
|
||||
await page.route(
|
||||
(url) => url.pathname.startsWith("/api/"),
|
||||
(route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path === "/api/alerts/active" || path === "/api/orgs")
|
||||
return route.fulfill({ json: [] });
|
||||
if (path === "/api/version")
|
||||
return route.fulfill({
|
||||
json: { version: "6.5.0-test", isDevelopment: true },
|
||||
});
|
||||
if (path === "/api/updates/check")
|
||||
return route.fulfill({
|
||||
json: {
|
||||
available: false,
|
||||
currentVersion: "6.5.0-test",
|
||||
latestVersion: "6.5.0-test",
|
||||
},
|
||||
});
|
||||
if (path === "/api/updates/release-notes")
|
||||
return route.fulfill({
|
||||
json: { version: "6.5.0-test", releaseNotes: "" },
|
||||
});
|
||||
if (path === "/api/security/status")
|
||||
return route.fulfill({
|
||||
json: { hasAuthentication: true, sessionCapabilities: {} },
|
||||
});
|
||||
if (path === "/api/state") return route.fulfill({ json: state() });
|
||||
if (path === "/api/state/summary") return route.fulfill({ json: {} });
|
||||
if (path === "/api/health")
|
||||
return route.fulfill({ json: { status: "healthy" } });
|
||||
if (path === "/api/resources") {
|
||||
const types = new URL(route.request().url()).searchParams
|
||||
.get("type")
|
||||
?.split(",");
|
||||
const resources = state().resources.filter(
|
||||
(r) => !types || types.includes(r.type),
|
||||
);
|
||||
const query = new URL(route.request().url()).searchParams;
|
||||
const limit = Number(query.get("limit") || 100);
|
||||
const pageNumber = Number(query.get("page") || 1);
|
||||
return route.fulfill({
|
||||
json: {
|
||||
resources: resources.slice(
|
||||
(pageNumber - 1) * limit,
|
||||
pageNumber * limit,
|
||||
),
|
||||
meta: { totalPages: Math.ceil(resources.length / limit) },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/backups/pve")
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
guestSnapshots: workloads().map((w) => ({
|
||||
id: `snap-${w.id}`,
|
||||
name: "pre-upgrade",
|
||||
node: "pve-a",
|
||||
instance: "pve-a",
|
||||
type: "ct",
|
||||
vmid: w.proxmox.vmid,
|
||||
time: new Date().toISOString(),
|
||||
vmstate: false,
|
||||
})),
|
||||
storageBackups: [],
|
||||
backupTasks: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (path === "/api/backups/pbs")
|
||||
return route.fulfill({ json: { data: { backups: [] } } });
|
||||
return route.fulfill({ json: { data: [], policy: {} } });
|
||||
},
|
||||
);
|
||||
await page.goto("http://127.0.0.1:5198/proxmox/backups/coverage");
|
||||
await expect(
|
||||
page.getByText("pbs-main-snapshot-0", { exact: true }),
|
||||
).toBeVisible({ timeout: 60000 });
|
||||
const toggle = page.getByRole("button", {
|
||||
name: /expand details for guest-20-/i,
|
||||
});
|
||||
// The narrow layout exposes this control on keyboard focus, not pointer hover.
|
||||
await toggle.waitFor({ timeout: 60_000 }).catch(async (e) => {
|
||||
console.error(await page.locator("body").innerText());
|
||||
throw e;
|
||||
});
|
||||
await toggle.focus();
|
||||
await toggle.press("Enter").catch(async (error) => {
|
||||
console.error(errors, await page.locator("body").innerText());
|
||||
throw error;
|
||||
});
|
||||
const expanded = page.getByRole("button", {
|
||||
name: /collapse details for guest-20-/i,
|
||||
});
|
||||
await expanded.focus();
|
||||
await expect(
|
||||
page.getByText("Restore evidence", { exact: true }),
|
||||
).toBeVisible();
|
||||
// Finish layout/scroll synchronisation before measuring refresh effects.
|
||||
await page.waitForTimeout(500);
|
||||
const visibleCoverageRows = await page
|
||||
.locator('[data-proxmox-backup-row="coverage"]')
|
||||
.count();
|
||||
expect(visibleCoverageRows).toBeLessThan(240);
|
||||
expect(visibleCoverageRows).toBeGreaterThan(20);
|
||||
const baseline = await page.evaluate(() => ({
|
||||
y: document.querySelector(".app-scroll-shell").scrollTop,
|
||||
url: location.href,
|
||||
}));
|
||||
expect(baseline.y).toBeGreaterThan(100);
|
||||
const samples = [];
|
||||
for (generation = 1; generation <= 3; generation++) {
|
||||
expect(socket).toBeTruthy();
|
||||
socket.send(JSON.stringify({ type: "rawData", data: state() }));
|
||||
await expect(
|
||||
page
|
||||
.getByText(`guest-20-snapshot-${generation}`, { exact: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Restore evidence", { exact: true }),
|
||||
).toBeVisible();
|
||||
const sample = await page.evaluate(() => ({
|
||||
y: document.querySelector(".app-scroll-shell").scrollTop,
|
||||
url: location.href,
|
||||
}));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
generation,
|
||||
...sample,
|
||||
focused: await expanded.evaluate((el) => el === document.activeElement),
|
||||
}),
|
||||
);
|
||||
await expect(expanded).toBeFocused();
|
||||
expect(sample.url).toBe(baseline.url);
|
||||
expect(Math.abs(sample.y - baseline.y)).toBeLessThanOrEqual(1);
|
||||
samples.push({ generation, ...sample });
|
||||
}
|
||||
|
||||
// Navigate through the real router, then hold a scrolled By date view.
|
||||
await page.getByRole("link", { name: "By date", exact: true }).click();
|
||||
const dateRow = page
|
||||
.locator('[data-proxmox-backup-row="recoverable"]')
|
||||
.filter({ hasText: "guest-20-" })
|
||||
.first();
|
||||
await dateRow.scrollIntoViewIfNeeded();
|
||||
const dateTab = page.getByRole("link", { name: "By date", exact: true });
|
||||
await dateTab.evaluate((el) => el.focus({ preventScroll: true }));
|
||||
const capture = () =>
|
||||
page.evaluate(() => ({
|
||||
y: document.querySelector(".app-scroll-shell").scrollTop,
|
||||
url: location.href,
|
||||
active:
|
||||
document.activeElement?.getAttribute("aria-label") ||
|
||||
document.activeElement?.textContent,
|
||||
}));
|
||||
const dateBaseline = await capture();
|
||||
expect(dateBaseline.y).toBeGreaterThan(100);
|
||||
const dateSamples = [];
|
||||
for (generation = 4; generation <= 6; generation++) {
|
||||
socket.send(JSON.stringify({ type: "rawData", data: state() }));
|
||||
await expect(dateRow).toContainText(`guest-20-snapshot-${generation}`);
|
||||
await expect(dateTab).toBeFocused();
|
||||
const sample = await capture();
|
||||
expect(sample.url).toBe(dateBaseline.url);
|
||||
expect(Math.abs(sample.y - dateBaseline.y)).toBeLessThanOrEqual(1);
|
||||
dateSamples.push(sample);
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
view: "date",
|
||||
baseline: dateBaseline,
|
||||
samples: dateSamples,
|
||||
}),
|
||||
);
|
||||
|
||||
const pbsToggle = page.getByRole("button", {
|
||||
name: /Expand details for pbs-main-snapshot-/,
|
||||
});
|
||||
await pbsToggle.focus();
|
||||
await pbsToggle.press("Enter");
|
||||
const history = page.getByRole("tab", { name: "History", exact: true });
|
||||
await history.click();
|
||||
await history.focus();
|
||||
await expect(page.getByTestId("resource-metrics-history-tab")).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
const historyBaseline = await capture();
|
||||
const historySamples = [];
|
||||
for (generation = 7; generation <= 9; generation++) {
|
||||
socket.send(JSON.stringify({ type: "rawData", data: state() }));
|
||||
await expect(
|
||||
page
|
||||
.getByText(`pbs-main-snapshot-${generation}`, { exact: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(history).toHaveAttribute("aria-selected", "true");
|
||||
await expect(history).toBeFocused();
|
||||
await expect(
|
||||
page.getByTestId("resource-metrics-history-tab"),
|
||||
).toBeVisible();
|
||||
const sample = await capture();
|
||||
expect(sample.url).toBe(historyBaseline.url);
|
||||
expect(Math.abs(sample.y - historyBaseline.y)).toBeLessThanOrEqual(1);
|
||||
historySamples.push(sample);
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
view: "PBS History",
|
||||
baseline: historyBaseline,
|
||||
samples: historySamples,
|
||||
}),
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
expect(consoleErrors).toEqual([]);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
baseline,
|
||||
samples,
|
||||
visibleCoverageRows,
|
||||
pageErrors: errors,
|
||||
consoleErrors,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
}
|
||||
Reference in New Issue
Block a user