mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Preserve returned backup presentation and exact browser proof
Compose the existing Web completion without reparenting its source or own-parent receipts. Keep the guest guard, completed-backup truth, upstream disk hover and Unknown dot, and test-only exact Deferred assertions. Return the combined source for independent review and protected publication. Change-source: pulse-maintainer
This commit is contained in:
@@ -22,7 +22,7 @@
|
||||
|
||||
### Guest disk deferral and retained observations
|
||||
|
||||
The existing workload row, disk breakdown tooltip/list and guest Overview use
|
||||
The existing workload row, disk breakdown tooltip and guest Overview use
|
||||
one presentation for the fixed guest-read reason vocabulary. Operation locks,
|
||||
unverified lock clearance, an in-flight request, cooldown, incomplete response
|
||||
and capacity deferrals are not evidence that QGA is missing. Invalid VM identity
|
||||
@@ -38,12 +38,36 @@ updates values without remounting the row. Permission, disabled-agent and actual
|
||||
unavailable cases retain their own explanations.
|
||||
|
||||
Verification: `workloadGuestPresentation.test.ts`, the mounted `GuestRow`,
|
||||
`DiskList`, `StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions,
|
||||
`StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions,
|
||||
and `browser-tests/guest-disk-deferral.cjs` cover the fixed reasons, retained/no
|
||||
previous data and fresh resumption with production components and CSS on desktop
|
||||
Chromium and touch WebKit. Synthetic observations do not establish the native
|
||||
command-ID cause, guest thaw, fresh History collection or containing release.
|
||||
|
||||
The legacy Workloads `DiskList` is exercised only by tests/fixtures, not a
|
||||
shipped caller. Its empty-state title is not evidence of readable touch guidance;
|
||||
the shipped guest Overview supplies the readable pre-sample explanation.
|
||||
|
||||
### Completed guest backup evidence versus current activity
|
||||
|
||||
The guest Overview keeps the last completed backup age and its existing
|
||||
threshold-derived tone visible independently of current backup activity.
|
||||
Starting a backup must not hide absent or old completed evidence or turn it
|
||||
green. A separate cautionary activity row reports a running backup as not yet
|
||||
completed. Ending that activity without a new completed timestamp does not
|
||||
manufacture protection. A new completed timestamp updates the existing drawer
|
||||
without remounting it. Both values wrap at phone widths, rather than requiring
|
||||
hover to distinguish them.
|
||||
|
||||
Verification: the mounted `GuestDrawer.test.tsx` backup regressions,
|
||||
`workloadGuestPresentation.test.ts` and production full-drawer fixture
|
||||
`browser-tests/guest-backup-protection.cjs` cover VM/CT missing, stale and fresh
|
||||
completion evidence, concurrent activity, stopped activity with no completion,
|
||||
and later completed observations. They preserve independent disk-lock/timeout
|
||||
guidance and existing touch/keyboard collapse. Synthetic observations establish
|
||||
presentation, not native task success, archive restorability, QGA/thaw or release
|
||||
availability; those require their own containing native and delivery evidence.
|
||||
|
||||
|
||||
### Composed operator tables and conservative rollback consent
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ and `browser-tests/guest-disk-deferral.cjs` verify same-row ownership in bar and
|
||||
sparkline modes with desktop and phone CSS. This is client behaviour proof, not
|
||||
native backup coordination, new History samples or a fleet performance result.
|
||||
|
||||
Completed backup presentation follows the existing same-guest snapshot owner.
|
||||
Running activity and completed protection are independent derived detail rows;
|
||||
neither starts a command, adds a read/timer nor remounts the drawer. Stopping
|
||||
activity cannot synthesize a completed timestamp. `GuestDrawer.test.tsx` and
|
||||
`browser-tests/guest-backup-protection.cjs` verify in-place missing/old/new
|
||||
completion transitions through the actual drawer. This is ownership/presentation
|
||||
proof, not an allocation benchmark, native backup or fleet performance claim.
|
||||
|
||||
### Phone workload identity weights
|
||||
|
||||
The existing phone guest projection allocates 38 percent to the name cell,
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
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');
|
||||
|
||||
const baseline = false;
|
||||
const root = '/workspace/frontend-modern';
|
||||
const output = `/workspace/tmp/guest-backup-protection-${baseline ? 'parent' : 'final'}`;
|
||||
const runtime = [
|
||||
'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
|
||||
'frontend-modern/src/utils/workloadGuestPresentation.ts',
|
||||
'frontend-modern/src/components/Workloads/GuestDrawer.tsx',
|
||||
'frontend-modern/src/components/Workloads/useGuestDrawerState.ts',
|
||||
'frontend-modern/src/components/Workloads/guestDrawerModel.ts',
|
||||
];
|
||||
const sha256 = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const playwrightVersion = require('playwright/package.json').version;
|
||||
assert.equal(
|
||||
playwrightVersion,
|
||||
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
|
||||
'node_modules/@playwright/test'
|
||||
].version,
|
||||
);
|
||||
const hashes = Object.fromEntries(runtime.map((p) => [p, sha256(path.join('/workspace', p))]));
|
||||
process.chdir(root);
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
cacheDir: path.join(output, 'vite-cache'),
|
||||
server: { host: '127.0.0.1', port: 5296, strictPort: true },
|
||||
});
|
||||
const results = [],
|
||||
screenshots = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, dark] of [
|
||||
['desktop', chromium, 1365, false],
|
||||
['phone', webkit, 390, true],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
for (const kind of ['qemu', 'lxc']) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
isMobile: width === 390,
|
||||
hasTouch: width === 390,
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
page.setDefaultTimeout(15000);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
|
||||
const errors = [],
|
||||
writes = [],
|
||||
checks = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.route('**/*', (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
assert.equal(
|
||||
url.origin,
|
||||
'http://127.0.0.1:5296',
|
||||
'only guest-local fixture requests are allowed',
|
||||
);
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
if (request.method() !== 'GET') writes.push(`${request.method()} ${url.pathname}`);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: [],
|
||||
alerts: [],
|
||||
anomalies: [],
|
||||
config: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto(
|
||||
`http://127.0.0.1:5296/browser-tests/guest-backup-protection.html?kind=${kind}`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(() => window.__backupProtection);
|
||||
if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
assert.equal(await page.evaluate(() => window.innerWidth), width, 'actual CSS viewport');
|
||||
const details = page.getByTestId('guest-technical-details');
|
||||
await details.waitFor({ state: 'visible' });
|
||||
await page.evaluate(() => {
|
||||
window.__originalGuestDetails = document.querySelector(
|
||||
'[data-testid="guest-technical-details"]',
|
||||
);
|
||||
window.__originalGuestHeading = document.querySelector('h3');
|
||||
});
|
||||
const update = async (lastBackup, running, reason = '') => {
|
||||
await page.evaluate((value) => window.__backupProtection.update(value), {
|
||||
lastBackup,
|
||||
running,
|
||||
reason,
|
||||
});
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.querySelector('[data-testid="guest-technical-details"]') ===
|
||||
window.__originalGuestDetails,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__backupProtection.identity()),
|
||||
'fixture-pve:pve-a:101',
|
||||
);
|
||||
};
|
||||
const capture = async (suffix) => {
|
||||
const p = path.join(output, `${name}-${kind}-${suffix}.png`);
|
||||
await page.screenshot({ path: p, fullPage: true });
|
||||
screenshots.push({ path: path.relative('/workspace', p), sha256: sha256(p) });
|
||||
};
|
||||
const oldBackup = new Date('2026-09-23T12:00:00Z').getTime();
|
||||
if (baseline) {
|
||||
await update(0, true, kind === 'qemu' ? 'prev-vm-locked' : '');
|
||||
const activity = details.getByText('Backup running', { exact: true });
|
||||
await activity.waitFor({ state: 'visible' });
|
||||
assert((await activity.locator('..').getAttribute('class')).includes('text-emerald-700'));
|
||||
assert.equal(await details.getByText('No backup found', { exact: true }).count(), 0);
|
||||
checks.push(
|
||||
'parent: running backup hides missing completed protection and appears green',
|
||||
);
|
||||
if (kind === 'qemu') await capture('running-with-none');
|
||||
await update(oldBackup, true);
|
||||
assert.equal(await details.getByText('10d ago', { exact: true }).count(), 0);
|
||||
await update(oldBackup, false);
|
||||
await details.getByText('10d ago', { exact: true }).waitFor({ state: 'visible' });
|
||||
checks.push('parent: running backup hides existing ten-day-old completed evidence');
|
||||
} else {
|
||||
const missing = details.getByText('No completed backup found', { exact: true });
|
||||
const activity = details.getByText('Running · not completed yet', { exact: true });
|
||||
await missing.waitFor({ state: 'visible' });
|
||||
assert((await missing.locator('..').getAttribute('class')).includes('text-rose-700'));
|
||||
assert.equal(await details.getByText('Backup activity', { exact: true }).count(), 0);
|
||||
await update(0, true, kind === 'qemu' ? 'prev-vm-locked' : '');
|
||||
await activity.waitFor({ state: 'visible' });
|
||||
await missing.waitFor({ state: 'visible' });
|
||||
assert((await activity.locator('..').getAttribute('class')).includes('text-amber-700'));
|
||||
assert.equal(await details.getByText('Backup running', { exact: true }).count(), 0);
|
||||
if (kind === 'qemu')
|
||||
await details
|
||||
.getByText(/Using last known disk stats\. Guest reads paused/)
|
||||
.waitFor({ state: 'visible' });
|
||||
checks.push(
|
||||
'absent completion stays missing/danger while separate activity is cautionary; VM lock notice survives',
|
||||
);
|
||||
if (kind === 'qemu') await capture('running-with-none');
|
||||
await update(0, false);
|
||||
await missing.waitFor({ state: 'visible' });
|
||||
assert.equal(await activity.count(), 0);
|
||||
checks.push(
|
||||
'activity stopping without new completion evidence never manufactures protection',
|
||||
);
|
||||
await update(oldBackup, true);
|
||||
const age = details.getByText('10d ago', { exact: true });
|
||||
await age.waitFor({ state: 'visible' });
|
||||
assert((await age.locator('..').getAttribute('class')).includes('text-amber-700'));
|
||||
await activity.waitFor({ state: 'visible' });
|
||||
checks.push('stale completed age and caution tone remain visible during a new backup');
|
||||
if (kind === 'qemu') await capture('running-with-old');
|
||||
await update(oldBackup, false, kind === 'qemu' ? 'prev-agent-timeout' : '');
|
||||
await age.waitFor({ state: 'visible' });
|
||||
assert.equal(await activity.count(), 0);
|
||||
if (kind === 'qemu')
|
||||
await details
|
||||
.getByText(/Do not restart the guest agent during a backup/)
|
||||
.waitFor({ state: 'visible' });
|
||||
checks.push('ended activity preserves old completion and independent timeout safety');
|
||||
const freshBackup = new Date('2026-10-03T10:00:00Z').getTime();
|
||||
await update(freshBackup, false);
|
||||
const today = details.getByText('Today', { exact: true });
|
||||
await today.waitFor({ state: 'visible' });
|
||||
assert((await today.locator('..').getAttribute('class')).includes('text-emerald-700'));
|
||||
assert.equal(await missing.count(), 0);
|
||||
await update(freshBackup, true);
|
||||
await today.waitFor({ state: 'visible' });
|
||||
await activity.waitFor({ state: 'visible' });
|
||||
checks.push(
|
||||
'only new completed timestamp updates protection; fresh age stays separate from further activity',
|
||||
);
|
||||
await update(freshBackup, false);
|
||||
const close = page.getByRole('button', { name: 'Collapse backup-guest details' });
|
||||
if (name === 'phone') await close.tap();
|
||||
else {
|
||||
await close.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
assert.equal(await page.evaluate(() => window.__backupProtection.closeCount()), 1);
|
||||
checks.push(
|
||||
'existing collapse remains touch/keyboard usable and observations trigger no write',
|
||||
);
|
||||
assert.equal(
|
||||
await details.getByText('Last completed backup', { exact: true }).count(),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
true,
|
||||
);
|
||||
if (kind === 'qemu') await capture('fresh-completion');
|
||||
}
|
||||
assert.deepEqual(writes, []);
|
||||
assert.deepEqual(errors, []);
|
||||
results.push({
|
||||
name,
|
||||
kind,
|
||||
browser_version: browser.version(),
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
checks,
|
||||
page_errors: errors,
|
||||
writes,
|
||||
});
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
const receipt = {
|
||||
version: 1,
|
||||
result: baseline ? 'defect-reproduced' : 'passed',
|
||||
verified_at: new Date().toISOString(),
|
||||
content_sha256: hashes,
|
||||
playwright_version: playwrightVersion,
|
||||
results,
|
||||
screenshots,
|
||||
limitation:
|
||||
'Actual GuestDrawer and production CSS with synthetic VM/CT observations. Not a native backup, usable archive, QGA/thaw, installation or release result.',
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(output, 'browser-result.json'),
|
||||
JSON.stringify(receipt, null, 2) + '\n',
|
||||
);
|
||||
process.stdout.write(JSON.stringify(receipt) + '\n');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((e) => {
|
||||
process.stderr.write(e.stack + '\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Guest backup protection verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./guest-backup-protection.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
// Synthetic observations through the actual GuestDrawer, its state model and
|
||||
// production CSS. This fixture neither starts a backup nor establishes thaw.
|
||||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
|
||||
import { DarkModeContext } from '../src/contexts/appRuntime';
|
||||
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
|
||||
import type { WorkloadGuest } from '../src/types/workloads';
|
||||
import '../src/index.css';
|
||||
|
||||
syncAIRuntimeSettings({ discovery_enabled: false });
|
||||
|
||||
function Fixture() {
|
||||
const [observation, setObservation] = createSignal({
|
||||
lastBackup: 0,
|
||||
running: false,
|
||||
reason: '',
|
||||
});
|
||||
const [closeCount, setCloseCount] = createSignal(0);
|
||||
const type = new URLSearchParams(window.location.search).get('kind') === 'lxc' ? 'lxc' : 'qemu';
|
||||
const guest = (): WorkloadGuest => ({
|
||||
id: 'fixture-pve:pve-a:101',
|
||||
vmid: 101,
|
||||
name: 'backup-guest',
|
||||
node: 'pve-a',
|
||||
instance: 'fixture-pve',
|
||||
status: 'running',
|
||||
type,
|
||||
cpu: 0.1,
|
||||
cpus: 2,
|
||||
memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
|
||||
disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
|
||||
disks: [
|
||||
{ mountpoint: '/data', type: 'ext4', total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
|
||||
],
|
||||
diskStatusReason: observation().reason,
|
||||
networkIn: 0,
|
||||
networkOut: 0,
|
||||
diskRead: 0,
|
||||
diskWrite: 0,
|
||||
uptime: 3600,
|
||||
template: false,
|
||||
lastBackup: observation().lastBackup,
|
||||
backupInProgress: observation().running,
|
||||
tags: [],
|
||||
lock: observation().running ? 'backup' : '',
|
||||
lastSeen: '2026-10-03T12:00:00Z',
|
||||
});
|
||||
(window as any).__backupProtection = {
|
||||
update: setObservation,
|
||||
closeCount,
|
||||
identity: () => guest().id,
|
||||
};
|
||||
return (
|
||||
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content">
|
||||
<h1 class="text-base font-semibold">Guest backup protection</h1>
|
||||
<p class="text-xs text-muted">Synthetic observations; no backup command is sent.</p>
|
||||
<section aria-label="Guest details" class="mx-auto max-w-4xl">
|
||||
<GuestDrawer guest={guest()} onClose={() => setCloseCount((count) => count + 1)} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route
|
||||
path="*"
|
||||
component={() => (
|
||||
<DarkModeContext.Provider
|
||||
value={() => document.documentElement.classList.contains('dark')}
|
||||
>
|
||||
<Fixture />
|
||||
</DarkModeContext.Provider>
|
||||
)}
|
||||
/>
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -117,9 +117,9 @@ const held = [
|
||||
requests.push({ path: url.pathname, query: url.search, mode });
|
||||
if (mode.startsWith('hold-'))
|
||||
await new Promise((resolve) => releases.push({ isHeld, resolve }));
|
||||
if (mode.endsWith('denied'))
|
||||
if (mode.endsWith('denied') || mode.endsWith('unavailable'))
|
||||
return route.fulfill({
|
||||
status: isHeld ? 403 : 503,
|
||||
status: isHeld && mode.endsWith('denied') ? 403 : 503,
|
||||
json: { error: 'Synthetic unavailable evidence' },
|
||||
});
|
||||
return route.fulfill({
|
||||
@@ -240,6 +240,30 @@ const held = [
|
||||
assert.equal(await empty.count(), 0);
|
||||
},
|
||||
);
|
||||
await check(
|
||||
'The CI locator matches the 503 warning after the held row is withdrawn',
|
||||
async () => {
|
||||
heldMode = 'unavailable';
|
||||
await refresh.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await heldWarning.waitFor();
|
||||
const broadTexts = await page.getByText('Deferred').allTextContents();
|
||||
const exactBadgeCount = await activity.getByText('Deferred', { exact: true }).count();
|
||||
assert.deepEqual(broadTexts, [
|
||||
'Pulse could not read held or deferred notifications. Refresh to try again.',
|
||||
]);
|
||||
assert.equal(exactBadgeCount, 0);
|
||||
assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 1);
|
||||
checks.push({ label: 'Observed broad versus exact status match', broadTexts, exactBadgeCount });
|
||||
},
|
||||
);
|
||||
await capture('held-503-ci-locator');
|
||||
await check('A healthy refresh restores the held row before permission withdrawal', async () => {
|
||||
heldMode = 'known';
|
||||
await refresh.click();
|
||||
await activity.getByText('Deferred', { exact: true }).waitFor();
|
||||
await heldWarning.waitFor({ state: 'hidden' });
|
||||
});
|
||||
await check(
|
||||
'Held-event 403 withdraws held rows and warns without hiding delivered evidence',
|
||||
async () => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "7a5635b2c0b76e13f510cc7a27b7ca0cd55091ba",
|
||||
"verified_at": "2026-10-03T18:34:44.680Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
|
||||
"frontend-modern/src/utils/workloadGuestPresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "3c19f8214611369807c48715a4429f4913c346b687ba3fe40b43c8ce094e6bd9",
|
||||
"frontend-modern/src/utils/workloadGuestPresentation.ts": "8b648129348725216f14f6b06931222daa69439e4bee76add81324699b6266bf"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/guest-backup-protection.html?kind=qemu",
|
||||
"/browser-tests/guest-backup-protection.html?kind=lxc"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Missing completed backup, idle and running; missing protection stays danger, running activity is cautionary",
|
||||
"Existing ten-day-old completed backup remains visible and cautionary during a new backup",
|
||||
"Activity ends without a new completed timestamp: missing or stale protection is unchanged",
|
||||
"Fresh completed timestamp alone updates protection; later concurrent activity remains separate",
|
||||
"VM disk-lock and uncertain-timeout guidance remain independent of backup activity"
|
||||
],
|
||||
"interactions": [
|
||||
"Reactive same-ID guest observations through actual GuestDrawer, state model and production CSS; no technical-details wrapper remount",
|
||||
"Desktop keyboard focus and Enter collapse, phone touch collapse; no API writes or page errors",
|
||||
"Measured 390px phone CSS viewport, no page overflow and six candidate screenshots visually inspected"
|
||||
],
|
||||
"playwright_version": "1.56.1",
|
||||
"browser_versions": [
|
||||
{
|
||||
"engine": "chromium",
|
||||
"version": "141.0.7390.37"
|
||||
},
|
||||
{
|
||||
"engine": "webkit",
|
||||
"version": "26.0"
|
||||
}
|
||||
],
|
||||
"dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82",
|
||||
"limitations": [
|
||||
"Actual GuestDrawer and production CSS with synthetic VM/CT observations. Not a native backup, usable archive, QGA/thaw, installation or release result.",
|
||||
"Synthetic completion evidence does not prove the reported freeze cause or native thaw; no released-availability claim."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "d9a2c78612b21dd2e46de0f75b71c0ddaddb9f86",
|
||||
"verified_at": "2026-10-03T19:32:24.556252Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/StackedDiskBar.tsx",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "39f4000617cb62621c0abf5749a46eb26e769bb437ded70730f6d9727e358a55",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "41202d1ae6095d882b124a55aed4110c74f38047d51b83b07bf52a6e95ca6b01"
|
||||
},
|
||||
"routes": [
|
||||
"/combined-disk.html",
|
||||
"/browser-tests/backups.html",
|
||||
"/proxmox/backups/coverage",
|
||||
"/alerts/overview",
|
||||
"/alerts/notifications"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Retained fullest-filesystem 90% label and equal-weight micro-bars, missing observations and fresh 40% resumption",
|
||||
"Restored 30% desktop hover ring with retained-observation explanation",
|
||||
"Restored Unknown coverage dot at 40% rendered opacity on desktop; intentional dot-free compact phone filter stays readable",
|
||||
"Dated independent archive remains distinct from a fresh local snapshot, including snapshot-only scope and restored full scope",
|
||||
"Exact committed alert qualification spec: pending retention, 503 and 403 withdrawal of held rows only, warning through pending retry and healthy recovery"
|
||||
],
|
||||
"interactions": [
|
||||
"Desktop hover and inspected hovered/unhovered disk screenshots; phone label remains readable without hover",
|
||||
"By date and Coverage navigation, route-backed search, location selection/removal, phone Filters disclosure and inspected screenshots",
|
||||
"Keyboard Refresh with independently successful attempts and deliberately pending held responses; exact row badges and unavailable warnings are asserted separately in Chromium, mobile Chromium and phone WebKit"
|
||||
],
|
||||
"playwright_version": "1.56.1",
|
||||
"browser_versions": [
|
||||
{
|
||||
"engine": "chromium",
|
||||
"version": "141.0.7390.37"
|
||||
},
|
||||
{
|
||||
"engine": "webkit",
|
||||
"version": "26.0"
|
||||
}
|
||||
],
|
||||
"dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82",
|
||||
"evidence": [
|
||||
"Current Web outcome RECEIPTS.md retains raw browser results, the three-project exact-spec report and inspected screenshots."
|
||||
],
|
||||
"limitations": [
|
||||
"Production component/CSS and full application entry with synthetic observations and HTTP/WebSocket data only; not native appliance, backup/thaw, authentication, signed recovery or release acceptance.",
|
||||
"Fresh proof covers the two runtime files changed by merge composition. Earlier valid own-parent receipts remain preserved; this is not a new whole-range browser run."
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from '@solidjs/testing-library';
|
||||
import { Suspense } from 'solid-js';
|
||||
import { createSignal, Suspense } from 'solid-js';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import type { Memory, Disk, GuestNetworkInterface } from '@/types/api';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
@@ -922,7 +922,7 @@ describe('GuestDrawer', () => {
|
||||
it('shows "Today" for a backup from today', () => {
|
||||
const now = new Date('2026-03-02T10:00:00Z').getTime();
|
||||
render(() => <GuestDrawer guest={makeGuest({ lastBackup: now })} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Protection')).toBeInTheDocument();
|
||||
expect(screen.getByText('Last completed backup')).toBeInTheDocument();
|
||||
expect(technicalDetails().getByText('Today')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -966,9 +966,64 @@ describe('GuestDrawer', () => {
|
||||
expect(ageCell).toHaveClass('text-emerald-700');
|
||||
});
|
||||
|
||||
it('hides Backup card when lastBackup is 0 (falsy)', () => {
|
||||
render(() => <GuestDrawer guest={makeGuest({ lastBackup: 0 })} onClose={vi.fn()} />);
|
||||
expect(screen.queryByText('Backup')).not.toBeInTheDocument();
|
||||
it.each(['qemu', 'lxc'] as const)(
|
||||
'keeps absent completed protection visible while a %s backup runs',
|
||||
(type) => {
|
||||
solidRender(() => (
|
||||
<GuestDrawer
|
||||
guest={makeGuest({ type, lastBackup: 0, backupInProgress: true })}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
expect(technicalDetails().getByText('Last completed backup')).toBeInTheDocument();
|
||||
expect(technicalDetails().getByText('No completed backup found').closest('td')).toHaveClass(
|
||||
'text-rose-700',
|
||||
);
|
||||
expect(technicalDetails().getByText('Backup activity')).toBeInTheDocument();
|
||||
expect(
|
||||
technicalDetails().getByText('Running · not completed yet').closest('td'),
|
||||
).toHaveClass('text-amber-700');
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['2026-02-20T12:00:00Z', '10d ago', 'text-amber-700'],
|
||||
['2026-03-02T10:00:00Z', 'Today', 'text-emerald-700'],
|
||||
])('preserves completed backup age and tone during a new backup (%s)', (date, age, tone) => {
|
||||
solidRender(() => (
|
||||
<GuestDrawer
|
||||
guest={makeGuest({ lastBackup: new Date(date).getTime(), backupInProgress: true })}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
expect(technicalDetails().getByText(age).closest('td')).toHaveClass(tone);
|
||||
expect(technicalDetails().getByText('Running · not completed yet').closest('td')).toHaveClass(
|
||||
'text-amber-700',
|
||||
);
|
||||
});
|
||||
|
||||
it('never treats activity stopping as a completed backup and updates only on completion evidence', () => {
|
||||
const [guest, setGuest] = createSignal(makeGuest({ lastBackup: 0 }));
|
||||
const { container } = solidRender(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
|
||||
const drawer = container.querySelector('[data-testid="guest-technical-details"]');
|
||||
expect(drawer).not.toBeNull();
|
||||
expect(technicalDetails().queryByText('Backup activity')).not.toBeInTheDocument();
|
||||
setGuest({ ...guest(), backupInProgress: true });
|
||||
expect(technicalDetails().getByText('Running · not completed yet')).toBeInTheDocument();
|
||||
expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument();
|
||||
setGuest({ ...guest(), backupInProgress: false });
|
||||
expect(technicalDetails().queryByText('Backup activity')).not.toBeInTheDocument();
|
||||
expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument();
|
||||
setGuest({ ...guest(), lastBackup: new Date('2026-03-02T10:00:00Z').getTime() });
|
||||
expect(technicalDetails().queryByText('No completed backup found')).not.toBeInTheDocument();
|
||||
expect(technicalDetails().getByText('Today').closest('td')).toHaveClass('text-emerald-700');
|
||||
expect(container.querySelector('[data-testid="guest-technical-details"]')).toBe(drawer);
|
||||
});
|
||||
|
||||
it('reports missing completed protection rather than hiding it when lastBackup is 0', () => {
|
||||
solidRender(() => <GuestDrawer guest={makeGuest({ lastBackup: 0 })} onClose={vi.fn()} />);
|
||||
expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument();
|
||||
expect(technicalDetails().queryByText('Backup activity')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ interface GuestDrawerOverviewProps {
|
||||
export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
const protectionPresentation = () =>
|
||||
getWorkloadsGuestProtectionPresentation({
|
||||
backupInProgress: props.guest.backupInProgress,
|
||||
ageLabel: props.backupPresentation?.ageLabel,
|
||||
ageClass: props.backupPresentation?.ageClass,
|
||||
});
|
||||
@@ -88,9 +87,17 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
: undefined,
|
||||
}),
|
||||
makeDetailRow('Primary IP', props.ipAddresses[0]),
|
||||
makeDetailRow('Protection', protectionPresentation().label, {
|
||||
// Starting or ending an operation is not evidence of a completed
|
||||
// backup. Keep its activity separate from the last completed age.
|
||||
makeDetailRow('Last completed backup', protectionPresentation().label, {
|
||||
tone: protectionPresentation().tone,
|
||||
wrap: true,
|
||||
}),
|
||||
makeDetailRow(
|
||||
'Backup activity',
|
||||
props.guest.backupInProgress ? 'Running · not completed yet' : null,
|
||||
{ tone: 'warning', wrap: true },
|
||||
),
|
||||
makeDetailRow(
|
||||
'Identified service',
|
||||
props.discoveryIdentifiedSummary
|
||||
|
||||
@@ -45,12 +45,8 @@ describe('workloadGuestPresentation', () => {
|
||||
});
|
||||
|
||||
it('returns canonical compact protection context for object drawers', () => {
|
||||
expect(getWorkloadsGuestProtectionPresentation({ backupInProgress: true })).toEqual({
|
||||
label: 'Backup running',
|
||||
tone: 'success',
|
||||
});
|
||||
expect(getWorkloadsGuestProtectionPresentation({})).toEqual({
|
||||
label: 'No backup found',
|
||||
label: 'No completed backup found',
|
||||
tone: 'danger',
|
||||
});
|
||||
expect(
|
||||
@@ -59,6 +55,12 @@ describe('workloadGuestPresentation', () => {
|
||||
ageClass: 'text-yellow-600',
|
||||
}),
|
||||
).toEqual({ label: '3d ago', tone: 'warning' });
|
||||
expect(
|
||||
getWorkloadsGuestProtectionPresentation({
|
||||
ageLabel: 'Today',
|
||||
ageClass: 'text-green-600',
|
||||
}),
|
||||
).toEqual({ label: 'Today', tone: 'success' });
|
||||
});
|
||||
|
||||
it('presents a running backup as its own state, keeping the completed age', () => {
|
||||
|
||||
@@ -70,15 +70,11 @@ export function getWorkloadsGuestBackupTooltip(
|
||||
}
|
||||
|
||||
export function getWorkloadsGuestProtectionPresentation(options: {
|
||||
backupInProgress?: boolean;
|
||||
ageLabel?: string | null;
|
||||
ageClass?: string | null;
|
||||
}): WorkloadsGuestProtectionPresentation {
|
||||
if (options.backupInProgress) {
|
||||
return { label: 'Backup running', tone: 'success' };
|
||||
}
|
||||
if (!options.ageLabel) {
|
||||
return { label: 'No backup found', tone: 'danger' };
|
||||
return { label: 'No completed backup found', tone: 'danger' };
|
||||
}
|
||||
return {
|
||||
label: options.ageLabel,
|
||||
|
||||
@@ -367,12 +367,16 @@ test.describe('Alert operator qualification', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
let heldEventsAvailable = true;
|
||||
await page.route('**/api/alerts/events**', (route) =>
|
||||
route.fulfill({
|
||||
status: heldEventsAvailable ? 200 : 503,
|
||||
let heldEventsStatus = 200;
|
||||
let heldEventsBarrier: Promise<void> | null = null;
|
||||
let releaseHeldEvents: (() => void) | undefined;
|
||||
await page.route('**/api/alerts/events**', async (route) => {
|
||||
const status = heldEventsStatus;
|
||||
if (heldEventsBarrier) await heldEventsBarrier;
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: heldEventsAvailable
|
||||
body: status === 200
|
||||
? JSON.stringify([
|
||||
{
|
||||
id: 'held-1',
|
||||
@@ -386,8 +390,8 @@ test.describe('Alert operator qualification', () => {
|
||||
},
|
||||
])
|
||||
: JSON.stringify({ error: 'event log unavailable' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/alerts/overview', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByText('Delivery Node').first()).toBeVisible();
|
||||
@@ -407,21 +411,82 @@ test.describe('Alert operator qualification', () => {
|
||||
{ exact: false },
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Deferred')).toBeVisible();
|
||||
await expect(page.getByText('Quiet hours')).toBeVisible();
|
||||
await expect(page.getByText('Delivered', { exact: true })).toBeVisible();
|
||||
const activity = page.locator('#notification-delivery-activity');
|
||||
const deferred = activity.getByText('Deferred', { exact: true });
|
||||
const delivered = activity.getByText('Delivered', { exact: true });
|
||||
const heldWarning = activity.getByRole('alert').filter({
|
||||
hasText: 'Pulse could not read held or deferred notifications. Refresh to try again.',
|
||||
});
|
||||
const heldLoading = activity.getByText('Loading held and deferred notifications...', {
|
||||
exact: true,
|
||||
});
|
||||
const refresh = activity.getByRole('button', { name: 'Refresh delivery status' });
|
||||
await expect(deferred).toBeVisible();
|
||||
await expect(activity.getByText('Quiet hours', { exact: true })).toBeVisible();
|
||||
await expect(delivered).toBeVisible();
|
||||
await expect(heldLoading).toHaveCount(0);
|
||||
await expect(heldWarning).toHaveCount(0);
|
||||
|
||||
heldEventsAvailable = false;
|
||||
const refresh = page
|
||||
.getByRole('heading', { name: 'Recent delivery activity' })
|
||||
.locator('..')
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Refresh delivery status' });
|
||||
// A pending refresh retains known evidence until its result arrives. The
|
||||
// independently successful attempt read must keep Refresh usable.
|
||||
heldEventsStatus = 503;
|
||||
heldEventsBarrier = new Promise<void>((resolve) => { releaseHeldEvents = resolve; });
|
||||
await refresh.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.getByText('Deferred')).toHaveCount(0);
|
||||
await expect(page.getByText('Delivered', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Delivery history is unavailable/i)).toHaveCount(0);
|
||||
await expect(heldLoading).toBeVisible();
|
||||
await expect(deferred).toBeVisible();
|
||||
await expect(delivered).toBeVisible();
|
||||
await expect(refresh).toBeEnabled();
|
||||
releaseHeldEvents!();
|
||||
heldEventsBarrier = null;
|
||||
await expect(heldWarning).toBeVisible();
|
||||
await expect(heldLoading).toHaveCount(0);
|
||||
// Substring text lookup is case-insensitive and also matches the warning's
|
||||
// "deferred notifications". Assert the exact status badge, not useful copy.
|
||||
await expect(deferred).toHaveCount(0);
|
||||
await expect(activity.getByText('Delivery Node (cpu)', { exact: true })).toHaveCount(0);
|
||||
await expect(delivered).toBeVisible();
|
||||
await expect(activity.getByText(/could not read the delivery log/i)).toHaveCount(0);
|
||||
await expect(activity.getByText(/No alert deliveries were attempted/)).toHaveCount(0);
|
||||
|
||||
heldEventsStatus = 200;
|
||||
await refresh.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(deferred).toBeVisible();
|
||||
await expect(heldWarning).toHaveCount(0);
|
||||
|
||||
// Permission withdrawal removes previously readable held evidence, not
|
||||
// independent delivery attempts.
|
||||
heldEventsStatus = 403;
|
||||
const denied = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname === '/api/alerts/events' && response.status() === 403,
|
||||
);
|
||||
await refresh.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await denied;
|
||||
await expect(heldLoading).toHaveCount(0);
|
||||
await expect(heldWarning).toBeVisible();
|
||||
await expect(deferred).toHaveCount(0);
|
||||
await expect(delivered).toBeVisible();
|
||||
|
||||
// A pending retry keeps the failure visible; only a healthy held read
|
||||
// restores that source's rows and clears its warning.
|
||||
heldEventsStatus = 200;
|
||||
heldEventsBarrier = new Promise<void>((resolve) => { releaseHeldEvents = resolve; });
|
||||
await refresh.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(heldLoading).toBeVisible();
|
||||
await expect(heldWarning).toBeVisible();
|
||||
await expect(deferred).toHaveCount(0);
|
||||
await expect(delivered).toBeVisible();
|
||||
await expect(refresh).toBeEnabled();
|
||||
releaseHeldEvents!();
|
||||
heldEventsBarrier = null;
|
||||
await expect(deferred).toBeVisible();
|
||||
await expect(heldLoading).toHaveCount(0);
|
||||
await expect(heldWarning).toHaveCount(0);
|
||||
await expect(delivered).toBeVisible();
|
||||
|
||||
});
|
||||
|
||||
test('mounts a bounded runway and renders 10,000 History records within budget', async ({ page }, testInfo) => {
|
||||
|
||||
Reference in New Issue
Block a user