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(); +}