mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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')!,
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user