diff --git a/frontend-modern/browser-tests/backups.html b/frontend-modern/browser-tests/backups.html index f22750f1cb..66a352fb93 100644 --- a/frontend-modern/browser-tests/backups.html +++ b/frontend-modern/browser-tests/backups.html @@ -2,6 +2,7 @@
+ diff --git a/frontend-modern/browser-tests/composed-backups.cjs b/frontend-modern/browser-tests/composed-backups.cjs new file mode 100644 index 0000000000..fd000eb8e1 --- /dev/null +++ b/frontend-modern/browser-tests/composed-backups.cjs @@ -0,0 +1,333 @@ +// Production orchestration/table/CSS with synthetic read-only HTTP evidence. +// Confirms combined Tailwind rendering and source attribution, not native backup recovery. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +(async () => { + const root = '/workspace/frontend-modern'; + const output = '/workspace/tmp/composed-backups-proof'; + fs.mkdirSync(output, { recursive: true }); + process.chdir(root); + const playwright = require('playwright/package.json').version; + assert.equal( + playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5295, strictPort: true }, + }); + const runtime = [ + 'frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx', + 'frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx', + ]; + const content_sha256 = Object.fromEntries( + runtime.map((p) => [ + p, + crypto + .createHash('sha256') + .update(fs.readFileSync(path.join('/workspace', p))) + .digest('hex'), + ]), + ); + const observations = []; + let browser; + try { + await server.listen(); + for (const [name, engine, phone] of [ + ['chromium-desktop', chromium, false], + ['webkit-phone', webkit, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }, + hasTouch: phone, + isMobile: phone, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(20000); + await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z')); + const errors = [], + requests = [], + offOrigin = [], + checks = [], + screenshots = []; + const now = '2026-10-03T12:00:00Z'; + const workload = { + id: 'ct-310', + type: 'system-container', + name: 'artifact-cache-310', + displayName: 'artifact-cache-310', + platformId: 'fixture-estate', + platformType: 'proxmox-pve', + sourceType: 'api', + status: 'running', + lastSeen: now, + proxmox: { vmid: 310, node: 'pve-edge', instance: 'homelab' }, + }; + const archive = { + id: 'file-310', + storage: 'local', + node: 'pve-edge', + instance: 'homelab', + type: 'ct', + vmid: 310, + time: '2026-09-20T02:00:00Z', + ctime: Date.parse('2026-09-20T02:00:00Z') / 1000, + size: 1048576, + format: 'tar.zst', + protected: false, + volid: 'local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst', + isPBS: false, + verified: false, + }; + const snapshot = { + id: 'snap-310', + name: 'fresh-local-snapshot', + node: 'pve-edge', + instance: 'homelab', + type: 'ct', + vmid: 310, + time: '2026-10-03T11:30:00Z', + vmstate: false, + }; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5295') { + offOrigin.push(url.origin); + return route.abort(); + } + if (url.pathname === '/fixture/workloads') return route.fulfill({ json: [workload] }); + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push({ path: url.pathname, method: request.method() }); + assert.equal(request.method(), 'GET'); + if (url.pathname === '/api/backups/pve') + return route.fulfill({ + json: { + data: { backupTasks: [], storageBackups: [archive], guestSnapshots: [snapshot] }, + }, + }); + if (url.pathname === '/api/backups/pbs') + return route.fulfill({ json: { data: { backups: [] } } }); + if (url.pathname === '/api/recovery/postures') + return route.fulfill({ + json: { + data: [], + policy: { + freshnessWindowSeconds: 0, + verificationWindowSeconds: 0, + requireVerification: false, + }, + meta: { page: 1, limit: 200, total: 0, totalPages: 0 }, + }, + }); + return route.fulfill({ + json: + url.pathname === '/api/license/runtime-capabilities' + ? { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + } + : { data: [], enabled: false }, + }); + }); + try { + await page.goto('http://127.0.0.1:5295/browser-tests/backups.html', { + waitUntil: 'domcontentloaded', + timeout: 120000, + }); + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + const capture = async (state) => { + await page.waitForTimeout(100); + const dimensions = await page.evaluate(() => ({ + width: innerWidth, + scroll: document.documentElement.scrollWidth, + })); + assert.equal( + dimensions.width, + phone ? 390 : 1365, + 'actual CSS viewport, not only Playwright emulation', + ); + assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions)); + const file = `${name}-${state}.png`; + await page.screenshot({ path: path.join(output, file), fullPage: true }); + screenshots.push({ file, dimensions }); + }; + await page.getByRole('link', { name: 'By date', exact: true }).click(); + const byDate = page.locator('[data-proxmox-backups-table="recoverable"]'); + await byDate.getByText('artifact-cache-310', { exact: true }).first().waitFor(); + await capture('by-date-diagnostic'); + fs.writeFileSync( + path.join(output, `${name}-by-date-diagnostic.json`), + JSON.stringify({ text: await byDate.innerText(), requests, errors }, null, 2), + ); + if (!phone) { + await byDate.getByText('tar.zst', { exact: true }).waitFor(); + assert.ok(await byDate.locator(`[title="${archive.volid}"]`).count()); + } + checks.push( + 'Explicit by-date navigation retains source, archive format and complete wide identifier', + ); + await capture('by-date'); + await page.getByRole('link', { name: 'Coverage', exact: true }).click(); + const coverage = page.locator('[data-proxmox-backups-table="coverage"]'); + await coverage.getByText('artifact-cache-310', { exact: true }).waitFor(); + const columns = await coverage + .locator('colgroup col') + .evaluateAll((elements) => elements.map((e) => e.dataset.proxmoxBackupsColumn)); + const row = coverage.locator('tr[data-proxmox-backup-row="coverage"]').first(); + const backupAge = await row.locator('td').nth(columns.indexOf('latest')).innerText(); + assert.ok(backupAge.includes('13d'), backupAge); + assert.ok(!backupAge.includes('30m'), backupAge); + if (columns.includes('snapshot')) + assert.ok( + (await row.locator('td').nth(columns.indexOf('snapshot')).innerText()).includes('30m'), + ); + checks.push('Fresh local guest snapshot does not masquerade as a recent backup'); + await capture('coverage'); + const search = page.getByPlaceholder('Search backups by workload, node, source, or status'); + await search.fill('non-existent-fixture'); + await page + .getByText('No workload coverage rows match current filters', { exact: true }) + .waitFor(); + await search.fill('artifact-cache'); + await coverage.getByText('artifact-cache-310', { exact: true }).waitFor(); + checks.push('Coverage route query filters and recovery preserve the target'); + if (phone && !(await page.getByRole('combobox', { name: 'Filter', exact: true }).count())) + await page.getByRole('button', { name: /^Filters/ }).click(); + await page + .getByRole('combobox', { name: 'Filter', exact: true }) + .selectOption({ label: 'Backup location: homelab / pve-edge' }); + await page.waitForFunction( + () => new URL(location.href).searchParams.get('location') === 'snapshot:homelab:pve-edge', + ); + const scopedColumns = await coverage + .locator('colgroup col') + .evaluateAll((elements) => elements.map((e) => e.dataset.proxmoxBackupsColumn)); + const scopedAge = row.locator('td').nth(scopedColumns.indexOf('latest')); + await scopedAge.getByText('None', { exact: true }).waitFor(); + assert.ok( + (await scopedAge.locator('span[title]').getAttribute('title')).includes( + 'Guest snapshots alone do not prove independent recovery.', + ), + ); + checks.push( + 'Snapshot-only location scope reports no independent backup, not a fresh restore promise', + ); + await capture('snapshot-only-location'); + if ( + phone && + !(await page + .getByRole('button', { name: 'Remove Backup location filter', exact: true }) + .count()) + ) + await page.getByRole('button', { name: /^Filters/ }).click(); + await page + .getByRole('button', { name: 'Remove Backup location filter', exact: true }) + .click(); + await page.waitForFunction(() => !new URL(location.href).searchParams.has('location')); + await row + .locator('td') + .nth(columns.indexOf('latest')) + .getByText(phone ? '13d' : '13d ago', { exact: true }) + .waitFor(); + checks.push('Removing location scope restores the independently dated archive'); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + assert.ok( + requests.some((r) => r.path === '/api/backups/pve') && + requests.some((r) => r.path === '/api/backups/pbs'), + ); + observations.push({ + name, + browser: browser.version(), + viewport: page.viewportSize(), + checks, + requests, + errors, + offOrigin, + screenshots, + }); + } catch (error) { + fs.writeFileSync( + path.join(output, `${name}-failure.json`), + JSON.stringify( + { + error: error.message, + text: await page.locator('body').innerText(), + url: page.url(), + requests, + errors, + offOrigin, + }, + null, + 2, + ), + ); + await page.screenshot({ path: path.join(output, `${name}-failure.png`), fullPage: true }); + throw error; + } + await browser.close(); + browser = null; + } + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + passed: true, + playwright, + content_sha256, + harness_sha256: crypto + .createHash('sha256') + .update(fs.readFileSync(__filename)) + .digest('hex'), + fixture_sha256: Object.fromEntries( + ['backups.html', 'backups.tsx'].map((file) => [ + file, + crypto + .createHash('sha256') + .update(fs.readFileSync(path.join(root, 'browser-tests', file))) + .digest('hex'), + ]), + ), + observations, + limitation: + 'Synthetic production component rendering, not native backup, QGA/thaw, installation or shipment.', + }, + null, + 2, + ), + ); + console.log( + JSON.stringify({ + passed: true, + browsers: observations.length, + checks: observations.reduce((n, o) => n + o.checks.length, 0), + }), + ); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-verification/86320370b56dc686.json b/frontend-modern/browser-verification/86320370b56dc686.json new file mode 100644 index 0000000000..4d88950e96 --- /dev/null +++ b/frontend-modern/browser-verification/86320370b56dc686.json @@ -0,0 +1,116 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "50756d745ea7ae62d3e1bbdccd03a03ce8963a1a", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx", + "frontend-modern/src/components/Storage/EnhancedStorageBar.tsx", + "frontend-modern/src/components/Storage/StoragePoolsTable.tsx", + "frontend-modern/src/components/Workloads/DiskList.tsx", + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/components/Workloads/GuestRow.tsx", + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx", + "frontend-modern/src/components/shared/HistoryChartOverlay.tsx", + "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx", + "frontend-modern/src/features/docker/DockerServicesTable.tsx", + "frontend-modern/src/features/docker/DockerTasksTable.tsx", + "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts", + "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx", + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts", + "frontend-modern/src/features/storageBackups/diskPresentation.ts", + "frontend-modern/src/features/storageBackups/storagePagePresentation.ts", + "frontend-modern/src/utils/workloadGuestPresentation.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "4fc443b465a7fe78df9f2b3faad6337bdddd118f9cfd33faa9217aaeb2d43c1b", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "b922b64a4388855c29296b178df553c60ba9395bd63d1433f79c6112030c8399", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "aca82b1f7a6f330d1f6fd7bdb6301420d3a7bb4f7f22fa5666e013a8a656ed26", + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "ddad24a010a2a194f37977f3c3aa3fa647b431bd65d1d75e9f5aea6d42f72692", + "frontend-modern/src/components/Storage/EnhancedStorageBar.tsx": "8021e55b50564fc9dfa29ebb83a824541b3ba34a1a6fc0fcc3793af5e5e4f701", + "frontend-modern/src/components/Storage/StoragePoolsTable.tsx": "abab4dcd8a67c39a846a97e46d3ba4e910b577f84318ed1e9c6a2c1cb6dc81fa", + "frontend-modern/src/components/Workloads/DiskList.tsx": "b9828bd9a9593093cfaeee5816a644b22177c0e91146c1592e01149a1c833793", + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "83a0d0c00786ab3a9115d0534a6d5a08f3c428109469a932c7d4f7fd9accc2b4", + "frontend-modern/src/components/Workloads/GuestRow.tsx": "33ce83ef5a529cbab740326de6c6b04ac62ca2024f07f3414262f68c69727186", + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "ff67b81bc5722f27f49820aeed76e5cc20b770cbd2fae4dbd1f267b10519995d", + "frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "a41425ff8dc2cd0768c7a9bf2f1192a3a058498d002ba3e8723979c5aaa60950", + "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx": "75530d5d16a579d415a5bc2c9f86e507555de1446d06529315b6c31919b03e03", + "frontend-modern/src/features/docker/DockerServicesTable.tsx": "a6d61e02ed31912abe9d1a33776a923412bef9aaa03b44d84facee723e0cc190", + "frontend-modern/src/features/docker/DockerTasksTable.tsx": "6cc99405c7bdb2b2edfd992f6b17b2636bb9285a53e949e41609dfa8b339cacf", + "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "5d7362905632cf4e69c4a878f972ac40544bda2959db2822a431bcc74ba35089", + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b791a50845befbdcfdca979355188fb1e977878ce69e8a72cbbdf90fda5e8cbe", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "97a95f97c94795005300fe55fdca81756b6ce9f11e977d1a3f683775eb0dc88d", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "f85558ab1092078b9a3d160c986450d2167552b10b2f76c10087fceef5af0689", + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "1dc95dd84f499e38a8fca77650ae72ff6c903b7c03c8fdc3cf366b309817e488", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "80079e8de47bbaa368ff511445209fe61fdbcd7ac0ce8003ef7d90ab338df2de", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "908af893071386d1a11a7191694b9fef399e4b134e78188bee90faa80f758e91", + "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx": "426d43f1635b659949e2317740ca09b54dfff89b1cd93c4dc6035a6b82fc2d13", + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "90670aaa66ce08fb90570b757029e10ea232c21f2e64ea4ce5e6cc3daa829d41", + "frontend-modern/src/features/storageBackups/diskPresentation.ts": "6e1faea3be9f1c7bacc8bc74ee03b2849c0e254bdee29cf6fb912c90d0ae6623", + "frontend-modern/src/features/storageBackups/storagePagePresentation.ts": "51a917ac758ce2611f444d6c37240de1e706282adad81f1feaa621aee939d115", + "frontend-modern/src/utils/workloadGuestPresentation.ts": "c51181ed80bc33907d331f2fd646b5aa209ebde622fb1ec1b6b70b8dcc2fd7d8" + }, + "routes": [ + "/browser-tests/guest-disk-deferral.html", + "/browser-tests/proxmox-docker-rows.html", + "/browser-tests/operator-tables-consent.html", + "/browser-tests/notification-evidence.html", + "/browser-tests/resource-evidence-access.html", + "/browser-tests/history-access.html", + "/browser-tests/history-keyboard.html", + "/browser-tests/drawer-snapshot.html", + "/browser-tests/backups.html" + ], + "states": [ + "Eight guest disk deferral/uncertainty families with and without retained observations; seven distinct unavailable states; fresh same-VM resumption", + "PBS/NAS pool health and capacity, full SMART reason, Kubernetes absolute started/completed and last-run context", + "REST-mapped Docker/PMG/storage rows and disclosures; fresh and missing facets; Swarm lifecycle details", + "Backup age, source and archive detail remain independent of guest snapshots, including snapshot-only repository scope", + "Notification initial/held pending, denied/unavailable and retained-transient read states", + "History/action denial withdraws prior samples, cache and fallback; transient failures retain truthful last-known evidence; pending retry and remount", + "Same-ID drawer replacement, target withdrawal, explicit selection and removed/restored availability checks", + "History overlay keyboard focus, arrow/Home/End bounds, grouped tooltips, Escape and empty data", + "Rollback consent reports unknown restore scope; rejection keeps consent and pending submissions cannot be duplicated or cancelled" + ], + "interactions": [ + "Open/close real rows and detail disclosures; hover disk bars on desktop; use phone touch controls", + "Select By date and Coverage using real router links; query filter to empty and back; scope to snapshot-only location and remove scope", + "Retry History and action/facet reads; select target and range; retain denial during pending refresh; read fresh successful result", + "Keyboard activation, Tab, Enter, arrow keys, Home, End and Escape; cancel restore before mutation and observe synthetic rejection/pending acceptance", + "Inspect actual desktop and phone screenshots and assert rendered bounds and final data, not just route or HTTP success" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 1440, + "height": 900 + }, + { + "width": 768, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "verified_at": "2026-10-03T16:56:27Z", + "evidence": [ + "Current invocation OUTCOME/RECEIPTS.md and hash-verified browser artifacts (nine production-component fixture families), synthetic production-component/CSS observations only" + ], + "limitations": [ + "Not native appliance/QGA/thaw, full-app/server, installation, signed persistent-state recovery or released acceptance.", + "Fresh additive receipt covers the 26 missing final contents against published main. Existing valid own-parent proofs are retained rather than rewritten." + ] +}