From ac8172c42c2ded7ae242d2beca04a0e034b04f8e Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:30:24 +0100 Subject: [PATCH] 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 --- .../subsystems/frontend-primitives.md | 6 + .../internal/subsystems/storage-recovery.md | 6 + .../internal/subsystems/unified-resources.md | 6 + frontend-modern/browser-tests/README.md | 23 +++ frontend-modern/browser-tests/backups.html | 10 ++ frontend-modern/browser-tests/backups.tsx | 31 ++++ frontend-modern/browser-verification.json | 34 ++--- .../features/proxmox/ProxmoxCoverageTable.tsx | 10 +- .../__tests__/ProxmoxBackupsTable.test.tsx | 9 +- scripts/check-backup-browser-polling.mjs | 142 ++++++++++++++++++ 10 files changed, 252 insertions(+), 25 deletions(-) create mode 100644 frontend-modern/browser-tests/README.md create mode 100644 frontend-modern/browser-tests/backups.html create mode 100644 frontend-modern/browser-tests/backups.tsx create mode 100644 scripts/check-backup-browser-polling.mjs diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index db477cc97..1193511ad 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 16e4fbbff..c4432a71c 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index dbe212996..a80ad64d1 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-tests/README.md b/frontend-modern/browser-tests/README.md new file mode 100644 index 000000000..17e8e503b --- /dev/null +++ b/frontend-modern/browser-tests/README.md @@ -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. diff --git a/frontend-modern/browser-tests/backups.html b/frontend-modern/browser-tests/backups.html new file mode 100644 index 000000000..f22750f1c --- /dev/null +++ b/frontend-modern/browser-tests/backups.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/frontend-modern/browser-tests/backups.tsx b/frontend-modern/browser-tests/backups.tsx new file mode 100644 index 000000000..d2835d51b --- /dev/null +++ b/frontend-modern/browser-tests/backups.tsx @@ -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([]); + onMount(() => { + const poll = async () => setWorkloads(await (await fetch('/fixture/workloads')).json()); + void poll(); + const timer = setInterval(() => void poll(), 1000); + onCleanup(() => clearInterval(timer)); + }); + return ( +
+ } workloads={workloads()} /> +
+ ); +} +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 2144f59ee..2497ac4b0 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx index 1e861196b..b3e34b1b2 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx @@ -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([]); + createEffect(() => setStableRows(reconcile([...props.rows], { key: 'key' }))); + const tableWindow = useProxmoxBackupTableWindowing(() => [...stableRows]); return ( { it('keeps coverage evidence expanded across repeated workload snapshots', async () => { mockBackupAPIs(); const [workloads, setWorkloads] = createSignal([workloadResource]); - renderInRouter(() => ( - } workloads={workloads()} /> - )); + renderInRouter(() => } 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); diff --git a/scripts/check-backup-browser-polling.mjs b/scripts/check-backup-browser-polling.mjs new file mode 100644 index 000000000..48f64c9f2 --- /dev/null +++ b/scripts/check-backup-browser-polling.mjs @@ -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(); +}