Preserve backup coverage row focus across polling snapshots

Reconcile coverage rows by logical keys before windowing. Isolated Chromium checks at desktop and narrow widths preserve keyboard focus, expanded evidence, route and scroll across replacement HTTP snapshots. Include subsystem completion obligations and a content-bound browser receipt.

Does not qualify the full application scroll-jump report in #1869 or reporter resolution.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-05 01:30:24 +01:00
parent 3c5d426543
commit ac8172c42c
10 changed files with 252 additions and 25 deletions
@@ -2549,6 +2549,12 @@ default` instead of fusing provider and badge text such as
## Completion Obligations
Coverage-table polling must preserve the DOM identity of an unchanged logical
row, including its focused expansion control, rather than keying rendering by
replacement snapshot object identity. The isolated Chromium polling check in
`scripts/check-backup-browser-polling.mjs` exercises this boundary with the
production table, router and styles; it does not qualify full-app scrolling.
1. Update guardrail tests when new shared primitives are added, including
new Settings controls that drive backend verification surfaces (for
example the Verify Patrol button in
@@ -2094,6 +2094,12 @@ take a correctness dependency on its contents.
## Completion Obligations
Coverage rows reconcile replacement recovery-model snapshots by their logical
`key` before windowing. Refreshed names must render without discarding expanded
restore evidence or keyboard focus on a surviving row. Verify with
`scripts/check-backup-browser-polling.mjs`; full provider/WebSocket polling and
the reported backup-page scroll jump remain outside that isolated fixture.
Agent-backed lifecycle recovery must namespace every in-memory pending request,
typed result, operation query, and deployment progress subscription by the
admitted organization session. That transport namespace must not alter the
@@ -1571,6 +1571,12 @@ served clones. Proof: `TestClonedResourcesPreservePlatformAdmission` and
## Completion Obligations
Replacement workload snapshots must update backup coverage content without
remounting surviving logical coverage rows. Preserve the focused expansion
control and expanded evidence across those updates. The isolated browser
polling regression verifies replacement HTTP fixture snapshots, not the full
application resource-provider or WebSocket lifecycle.
1. Update this contract when canonical resource identity or type rules change
2. Update contract and guardrail tests when a new resource type is added
3. Route runtime changes through the explicit unified-resource proof policies in `registry.json`; default fallback proof routing is not allowed
+23
View File
@@ -0,0 +1,23 @@
# Backup polling browser regression
From the repository root, after `npm ci` at the root and in `frontend-modern`:
```sh
pulse-heavy-run -- node scripts/check-backup-browser-polling.mjs
pulse-heavy-run -- env PULSE_BROWSER_WIDTH=390 node scripts/check-backup-browser-polling.mjs
```
Requires Playwright Chromium (`npx playwright install chromium`). The runner
starts and closes a loopback-only Vite server on port 5198 and a headless browser;
it neither needs nor contacts a live Pulse deployment. API responses are synthetic.
The fixture mounts the production backup table, router and CSS in a native
scroll container, with 40 workloads replaced by HTTP polling once per second.
It expands a mid-table workload and focuses its toggle, then verifies three
changed snapshot names render while focus, restore evidence, scroll offset and
the coverage route survive. Non-zero initial scroll prevents a vacuous pass.
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.
@@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/backups.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
// Browser fixture: real backup component and router; synthetic HTTP polling.
// Deliberately not a substitute for the full application/WebSocket path.
import { render } from 'solid-js/web';
import { createSignal, onMount, onCleanup } from 'solid-js';
import { Router, Route } from '@solidjs/router';
import { ProxmoxBackupsTable } from '../src/features/proxmox/ProxmoxBackupsTable';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
function Fixture() {
const [workloads, setWorkloads] = createSignal<Resource[]>([]);
onMount(() => {
const poll = async () => setWorkloads(await (await fetch('/fixture/workloads')).json());
void poll();
const timer = setInterval(() => void poll(), 1000);
onCleanup(() => clearInterval(timer));
});
return (
<main style={{ padding: '24px', height: '100vh', overflow: 'auto' }}>
<ProxmoxBackupsTable emptyIcon={<span />} workloads={workloads()} />
</main>
);
}
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
+14 -20
View File
@@ -1,41 +1,35 @@
{
"version": 1,
"base_sha": "485e19600f8bb8b9126fd4f2ab7e9fa2f525d808",
"verified_at": "2026-09-04T14:39:20Z",
"base_sha": "3c5d4265439d92495f35f2460c3f666330a239e5",
"verified_at": "2026-09-05T00:33:47Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx"
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx": "24ea681203713d0ddeeb75f49509876c38bfec73fddaaabe730f9b0ad69e0cf4"
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "0abd4bcd970e5836e8903d4140b638e7637a74b14dd1f2f6b53b338ae1e31fb3"
},
"routes": [
"/settings/infrastructure (managed local backend with a routed two-node Proxmox cluster fixture)"
"/proxmox/backups/coverage (isolated production component/router/CSS fixture; not full application)"
],
"viewports": [
{
"width": 1280,
"height": 720
},
{
"width": 393,
"height": 727
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 664
"height": 900
}
],
"states": [
"Manage dialog with unsaved node name, Host Telemetry Agent strategy and SSL verification retained after a 15-second connection-ledger poll",
"Expanded two-node Proxmox cluster editor open at desktop, Pixel and iPhone widths",
"Manage dialog dismissed with the infrastructure workspace scroll position and deliberate onward focus retained",
"Manage dialog reopened with immutable cluster-member display-name save targeting"
"40 synthetic workloads with mid-table restore evidence expanded",
"Three replacement HTTP polling snapshots render updated workload names with focused toggle and expanded evidence retained",
"Native scroll remains 532px desktop and 503px narrow; route unchanged and no page errors"
],
"interactions": [
"Edited the node name, changed setup strategy, enabled certificate verification and advanced the production polling clock while checking retained state and refreshed dialog context",
"Inspected desktop, Pixel and iPhone captures for placement, clipping, wrapping, scrolling, backdrop containment and close-control reachability",
"Closed Manage, moved focus to Add infrastructure without scrolling and waited beyond the delayed row-focus fallback",
"Reopened Manage, changed the second member display label and verified the PUT remained scoped to its immutable identity"
"Focus expansion toggle and press Enter, then retain focus across three replacement snapshots",
"Run pulse-heavy-run -- node scripts/check-backup-browser-polling.mjs",
"Run pulse-heavy-run -- env PULSE_BROWSER_WIDTH=390 node scripts/check-backup-browser-polling.mjs; keyboard interaction only, not touch qualification"
]
}
@@ -1,4 +1,6 @@
import { For, Show, createMemo, type Accessor, type JSX } from 'solid-js';
import { For, Show, createEffect, createMemo, type Accessor, type JSX } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
@@ -155,7 +157,11 @@ export function ProxmoxCoverageTable(props: {
const pbsSource = getProxmoxBackupSourcePresentation('pbs');
const archiveSource = getProxmoxBackupSourcePresentation('archive');
const snapshotSource = getProxmoxBackupSourcePresentation('snapshot');
const tableWindow = useProxmoxBackupTableWindowing(() => props.rows);
// Polling rebuilds recovery-model objects. Preserve logical row identity so
// Solid does not replace the focused toggle (or the browser's scroll anchor).
const [stableRows, setStableRows] = createStore<WorkloadCoverageRow[]>([]);
createEffect(() => setStableRows(reconcile([...props.rows], { key: 'key' })));
const tableWindow = useProxmoxBackupTableWindowing(() => [...stableRows]);
return (
<Show
@@ -498,14 +498,15 @@ describe('ProxmoxBackupsTable', () => {
it('keeps coverage evidence expanded across repeated workload snapshots', async () => {
mockBackupAPIs();
const [workloads, setWorkloads] = createSignal<readonly Resource[]>([workloadResource]);
renderInRouter(() => (
<ProxmoxBackupsTable emptyIcon={<span />} workloads={workloads()} />
));
renderInRouter(() => <ProxmoxBackupsTable emptyIcon={<span />} workloads={workloads()} />);
await screen.findAllByText('pbs-docker');
await fireEvent.click(screen.getByRole('link', { name: /coverage/i }));
await fireEvent.click(screen.getByRole('button', { name: /expand details for pbs-docker/i }));
const toggle = screen.getByRole('button', { name: /collapse details for pbs-docker/i });
toggle.focus();
for (let snapshot = 1; snapshot <= 3; snapshot += 1) {
const name = `pbs-docker-snapshot-${snapshot}`;
setWorkloads([{ ...workloadResource, name, displayName: name }]);
@@ -513,6 +514,8 @@ describe('ProxmoxBackupsTable', () => {
// Assert the new snapshot reached the rendered table, rather than merely
// checking that a stale expanded row survived.
await screen.findAllByText(name);
expect(screen.getByRole('button', { name: /collapse details for pbs-docker/i })).toBe(toggle);
expect(toggle).toHaveFocus();
expect(screen.getByRole('columnheader', { name: /posture/i })).toBeInTheDocument();
expect(screen.getByText('Restore evidence')).toBeInTheDocument();
expect(screen.getAllByText('PVE file').length).toBeGreaterThan(0);
+142
View File
@@ -0,0 +1,142 @@
// Run with pulse-heavy-run -- node scripts/check-backup-browser-polling.mjs
// Isolated real-browser component regression; no running Pulse instance needed.
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 },
plugins: [
{
name: "backup-browser-fixture",
configureServer(server) {
server.middlewares.use((req, _res, next) => {
if (req.url?.startsWith("/proxmox/backups/"))
req.url = "/browser-tests/backups.html";
next();
});
},
},
],
});
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 },
});
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") console.error(message.text());
});
page.on("requestfailed", (request) =>
console.error(request.url(), request.failure()),
);
let generation = 0;
const workloads = () =>
Array.from({ length: 40 }, (_, 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" },
}));
await page.route("**/fixture/workloads", (route) =>
route.fulfill({ json: workloads() }),
);
await page.route(
(url) => url.pathname.startsWith("/api/"),
(route) => {
const path = new URL(route.request().url()).pathname;
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");
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: 20_000 });
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();
const baseline = await page.evaluate(() => ({
y: document.querySelector("main").scrollTop,
url: location.href,
}));
expect(baseline.y).toBeGreaterThan(100);
const samples = [];
for (generation = 1; generation <= 3; generation++) {
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("main").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 });
}
expect(errors).toEqual([]);
console.log(
JSON.stringify({ baseline, samples, pageErrors: errors }, null, 2),
);
} finally {
await browser?.close();
await server.close();
}